返回博客列表
技术文章LLMRAGAgentAI

RAG 是怎么工作的——检索增强生成全链路拆解

2026年05月🇨🇳 中文

这是 AI 开发入门系列 的第 4 篇,讲 RAG(检索增强生成)的全链路。相关文章还有:

  1. 四种主流的 Agent 架构
  2. function call 与 tool call 的区别
  3. 用 Python 从零写一个 LLM Agent
  4. Prompt、Context、Harness——三种 Engineering 到底有什么不同

上一篇文章最后提到一个问题:Agent 的记忆越来越多,context window 塞不下怎么办。

这正是 RAG(Retrieval-Augmented Generation,检索增强生成)要解决的问题。它的思路很直接:不要把所有资料都塞进 prompt,而是先检索,找到与当前问题最相关的片段,只把这几段放进 context。

但 RAG 不是一种"架上去就完事"的技术。它是一条很长的链路,每个环节都有需要想清楚的选择。这篇文章从零拆这条链路,重点讲每个环节的决策点。

先看一下全貌,RAG 的完整流程分左右两条线:

上面是离线做的(文档来了之后跑一次),下面是每次用户提问都要实时跑一遍的。整条链路里有 5 个需要做选择的地方——文档怎么切、用哪个 embedding 模型、检索策略怎么定、要不要加 reranker、prompt 怎么拼。下面逐个讲。

RAG 全链路

一条 RAG 链路通常包含四个阶段:

  1. 索引(Indexing):把文档切块、向量化,存入向量数据库
  2. 检索(Retrieval):把用户问题向量化,在库里找最相似的文档块
  3. 增强(Augmentation):把检索到的内容拼成一段 prompt context
  4. 生成(Generation):LLM 基于检索内容给出回答

看起来是一条单向流水线,但实际落地之后你会发现,每一阶段都可能需要回头看前一阶段的决策是不是做错了。比如你发现检索效果不好,很可能问题不在检索而在索引——文档切的块太大,或者 embedding 模型选错了。

第一步:文档切块(Chunking),RAG 里最不性感但最重要的事

文档切块就是把一段长文本切成小块。听起来很简单,但切的粒度和方式会直接决定后面检索的质量。

如果你把一整篇 10 页的产品手册切成一个块,embedding 向量代表的是整篇手册的语义。用户问"这个产品的电池容量是多少",这个向量和"电池容量"的匹配度不会高——因为向量里还混了规格、保修、配件、使用说明那些语义。

如果你切成 100 字一个块,用户问"产品的三年质保条款是什么",恰好"三年"在第一块的末尾,"质保条款"在第二块的开头——检索结果里两个块都会出现,但单独看哪一块都不完整。

所以块大小(chunk size)是一个需要根据内容类型来定的参数:

内容类型建议块大小原因
技术文档(API 参考)200-500 字每块包含一个完整的概念或函数定义
文章 / 博客500-1000 字每块覆盖一个完整的段落或小节
对话记录 / 聊天2-5 轮对话孤立的单句常常丢失语境
代码库按函数/类一个完整的函数不应该被切在半中间

重叠(chunk overlap)是另一个容易被忽略的参数。两个相邻块之间有 10%-20% 的内容重叠,可以减少"关键信息刚好落在块边界"的问题。

切块的另一个选择是语义切块还是固定长度切块。固定长度(按字数/按 tokens)实现简单,但会在句子中间截断。语义切块(按段落、按标题、按自然断点)更准确,但实现复杂一些。实际项目里常见的做法是先按自然段落切,如果某一段太长再按句子切——这是个性价比很高的折中。

第二步:Embedding——选模型就是选"相似度的定义"

切好的文本块需要转成向量才能做相似度搜索。这个转换过程就是 embedding。

选 embedding 模型本质上是在选"什么叫相关"。不同模型对不同语言、不同领域的文本,能捕捉到的语义关系不一样。

几个常见选择:

  • OpenAI text-embedding-3-small:1536 维,性价比高,英文效果好,中文也能用但不是最优
  • OpenAI text-embedding-3-large:3072 维,准确度更高,但延迟和成本也更高
  • BGE 系列(BAAI):中文场景的首选之一,bge-large-zh 在中文基准测试中表现很好
  • Jina Embeddings v3:支持多语言,对小语种和混合语言场景友好

如果你做的是中文内容为主的 RAG,BGE 中文模型的效果通常好于 OpenAI 的通用模型。这个差距在常识类问题上不明显,但在领域特定的中文文档(法律、医疗、金融)上会拉得很大。

另外,embedding 有一个容易被忽略的问题:你的查询和你的文档可能不是同一种语言,或者同一种风格。 比如用户用口语问"这个东西怎么修",但你的文档是一本正式的维修手册。这种情况下,可以考虑用不同的 embedding 模型分别处理查询和文档(不对称 embedding),或者对查询做改写后再做向量化。

第三步:检索——相似度搜索只是起点

把用户问题转成向量后,就是最核心的步骤:在向量数据库中找到最相关的文档块。

最简单的做法是 cosine 相似度 + topK:

def retrieve(query_embedding, vector_db, top_k=5):
    scores = [
        (doc_id, cosine_similarity(query_embedding, doc_embedding))
        for doc_id, doc_embedding in vector_db
    ]
    scores.sort(key=lambda x: x[1], reverse=True)
    return scores[:top_k]

但纯相似度搜索有两个常见问题。

问题一:长尾文档被淹没。 如果你有 1000 篇文档,其中 800 篇都是类似的产品说明,用户问"产品 A 有什么特别的功能",相似度最高的结果可能不是"产品 A 的功能说明",而是 5 篇不同产品的功能说明——因为向量距离都很近,topK 返回的块不一定是你想要的那个。

问题二:语义匹配和精确匹配各有盲区。 用户搜"Python 3.12 的 match 语句怎么用",向量搜索可能返回一堆"Python 模式匹配""Python switch case 替代方案""Python 3.10 新特性"——语义上都相关,但没有一个准确指向 match 关键字的语法定义。这时候关键词搜索反而更准,因为它直接命中"match"这个符号。

所以实际项目里很少只用向量搜索。更常见的做法是混合搜索(hybrid search):同时跑向量搜索(语义匹配)和关键词搜索(精确匹配),然后把两路结果合并排序。Pgvector 和大多数向量数据库都支持这种模式。

合并排序也有学问。最简单的合并是 RRF(Reciprocal Rank Fusion):两路各给一个排名,取加权分数后重新排序。举例:

向量搜索结果排名:  [文档B, 文档A, 文档D, 文档C]
关键词搜索结果排名: [文档C, 文档A, 文档B, 文档E]

RRF 对每篇文档算分: score = 1/(k + rank)
  k 通常取 60

  文档A: 1/(60+2) + 1/(60+2) = 0.0323   ← 两路排名都靠前,总分最高
  文档B: 1/(60+1) + 1/(60+3) = 0.0323
  文档C: 1/(60+4) + 1/(60+1) = 0.0320
  文档D: 1/(60+3) + 0         = 0.0159   ← 只在向量路出现,自然排后
  文档E: 0         + 1/(60+4) = 0.0156

最终 RRF 排序: 文档A > 文档B > 文档C > 文档D > 文档E

文档 D 和 E 因为只在一路中出现,分数被自然压低——这恰好是你想要的:两路都认可的结果排名靠前。

RAG 为什么对 Agent 特别重要

在继续讲 reranking 和生成之前,先停一下。这些技术细节不是为了炫技——它们跟 Agent 的关系比"长期记忆太多时检索一下"要深得多。

一个完整的 Agent 系统会在三个环节用到 RAG:

  • 工具知识的检索。Agent 有 50 个工具,每个工具有自己的描述和参数文档。你不能把 50 个工具的定义全放进 prompt——放进去也超过 token 上限。RAG 可以让 Agent 先检索"当前问题最相关的 5 个工具",只暴露这 5 个给 LLM 选择。
  • 历史经验检索。Agent 之前做过类似的任务,那次怎么做的、踩了什么坑,这些信息放在长期记忆里,下次遇到类似场景时检索回来。这是 Agent 从"每次都从零思考"到"积累经验"的关键一步。
  • 领域知识注入。企业内部文档、技术手册、法规条款——这些静态知识不需要存在 LLM 参数里,RAG 在每次对话时动态检索即可。

所以 RAG 不只是"给 LLM 加点上下文"的技术。在一个完整的 Agent 系统里,它是记忆管理、知识接入和工具调度的基础设施。理解了这一点,后面的 reranking 和生成阶段的细节就更有目的性——你不是在优化一个"检索管道",而是在优化 Agent 决策链上的信息质量。

第四步:重排(Reranking),小成本撬动大提升

检索返回 topK 个文档块后,在送给 LLM 之前还可以过一遍 reranker。Reranker 是一个更精细的排序模型,它不像 embedding 那样只比较两个向量,而是直接接受"查询 + 文档"的文本对,输出一个相关性分数。

为什么需要 reranker?因为 embedding 向量把一篇 500 字的文档压缩成了 1536 个数字,这个压缩过程自然会丢失一些细节。而 reranker 是"读完全文再打分",对长文档中分散在多个段落里的信息更敏感。

常用的 reranker 包括 Cohere Rerank、BGE Reranker、cross-encoder 系列。一个典型的流程是:向量搜索取 topK=20 → reranker 重排 → 取 top 3-5 → 送给 LLM。

多出来的这一步,对问答质量的提升经常比换一个更好的 embedding 模型还明显。

第五步:生成——把检索结果变成答案的最后一步

最后一步看起来最简单:把检索到的文档块拼成 prompt,让 LLM 基于这些内容回答。但这步也有几个容易踩的坑。

第一个坑是上下文窗口规划不当。你不能把所有检索到的文档块都放进去——LLM 的注意力会分散,长上下文还会增加延迟和成本。一般的做法是按相关性排序后取前 3-5 块,如果这些块加起来已经超过 2000 tokens,就截断到 2000。

第二个坑是检索到的内容和问题不相关。如果你的 RAG 质量不够高,检索结果里混进了无关文档,LLM 可能会被带偏。解决方案是在 prompt 里明确告诉 LLM:

如果提供的资料不能回答用户的问题,请直接说"根据现有资料无法回答",不要编造信息。

第三个坑是引用。如果你的 RAG 系统需要告诉用户"这个信息来自哪里",文档块里需要附带来源元信息(文档名、段落编号、URL),并让 LLM 在回答里引用。

最小可行的 RAG 实现

如果你想把上面的概念跑通,下面的代码是真的可以复制到终端里执行的。它用 Chroma(轻量本地向量库)+ OpenAI embedding,对三篇手动构造的文档做一次检索问答:

import chromadb
from openai import OpenAI

client = OpenAI()
chroma = chromadb.Client()
collection = chroma.create_collection("my_docs")

# 1. 索引:三篇示例文档
documents = [
    "Python 3.12 新增了 match 语句,用于结构化模式匹配。",
    "match 语句的基本语法是 match value: case pattern: ...",
    "Python 是一种解释型语言,由 Guido van Rossum 创建。",
]
for i, doc in enumerate(documents):
    resp = client.embeddings.create(
        model="text-embedding-3-small", input=doc
    )
    collection.add(
        ids=[str(i)],
        embeddings=[resp.data[0].embedding],
        documents=[doc],
    )

# 2. 检索:用户提问
query = "match 语句怎么用"
query_resp = client.embeddings.create(
    model="text-embedding-3-small", input=query
)
results = collection.query(
    query_embeddings=[query_resp.data[0].embedding], n_results=2
)

# 3. 生成:把检索结果拼成 prompt 发给 LLM
context = "\n".join(results["documents"][0])
prompt = f"基于以下资料回答问题。如果资料不包含答案,请如实说。\n\n资料:\n{context}\n\n问题:{query}"
answer = client.chat.completions.create(
    model="gpt-4o", messages=[{"role": "user", "content": prompt}]
)
print(answer.choices[0].message.content)

这段代码跑完之后你会看到——对"match 语句怎么用"这个提问,Chroma 自动把三篇文档里最相关的两篇检索出来了,而不是那篇讲"Python 是什么"的。换成你自己的文档,改 documents 列表就行。后面的优化——换 embedding 模型、调 chunk size、加 reranker——都是在这个基础上做替换实验。

带知识库的 Agent 长什么样

读完 Agent 篇和这篇,你应该已经能想象它们合在一起的形态了。最简单的方式是把 Agent 循环里"手动注入记忆"的那一步,换成"先走一遍 RAG 检索 → 再注入结果":

def run_agent_with_knowledge(user_query, llm_call, tools, vector_db):
    # RAG: 检索相关文档
    query_vec = embed(user_query)
    docs = vector_db.search(query_vec, top_k=3)
    context = "\n".join(docs)

    # Agent: 把检索结果作为初始上下文注入
    messages = [
        {"role": "system", "content": f"你可以查阅以下资料来解决用户问题:\n{context}\n\n{tool_prompt}"},
        {"role": "user", "content": user_query},
    ]
    # 后续循环逻辑不变...

这就是 Agent + RAG 的最简结合点。工具调用、记忆管理、知识检索三件事收敛到同一个循环里,每条信息在进入 LLM 的 context window 之前都经过了"检索 → 筛选 → 注入"这个动作。