从零开始的LLM 6. RAG(检索增强生成)(一)

前言

从零开始学习ai文章系列已完成《动手学深度学习》和《磨菇书》两本书的学习,新开的LLM系列课本来自《Happy-LLM》和《Hello-Agents》和 《all-in-rag》,但是内容和排版等个人重新进行整理,因此不会按照原来课本中的章节来写。如果哪里有错误的欢迎指正。或者不清晰的可以直接查看原文部分。

由于 RAG 部分内容较多,单篇草稿已超过 1.4 万字,因此将拆分为上下两篇进行介绍。

整体内容基于对 all-in-rag 项目的学习与实践总结。

接下来,文章将以原项目中的 “第八章:项目实战一” 为主线,从整体流程入手,再逐步深入到各个模块的实现细节。

  • 篇章一:主要介绍 RAG 搜索引擎之外的内容,包括整体流程、系统架构以及各模块的作用等概述性内容。
  • 篇章二:重点讲解 RAG 搜索引擎的具体实现,以及其中涉及的关键技术与实现细节。

1. 什么是 RAG

RAG(Retrieval-Augmented Generation,检索增强生成)是一种在大语言模型(LLM)生成回答之前,先从外部知识库中检索相关信息,再结合这些信息进行回答的技术。

你可以将 RAG 理解为一个搜索工具

当用户提出一个问题时,它会先从知识库中搜索与问题相关的内容,然后再把这些内容提供给 LLM,帮助模型生成更加准确、更加符合事实的回答。

需要特别强调的是,RAG 是一个检索系统,而不是记忆系统。

很多初学者容易误以为,RAG 能够"记住"大量知识,或者能够"回忆"以前见过的内容。实际上并非如此。

RAG 的核心能力始终都是 Retrieval(检索),而不是 Memory(记忆)。它没有主动记忆能力,每一次用户发起查询时,都会重新执行一次完整的检索流程:

  1. 将用户的问题转换为向量表示(Embedding)。
  2. 在向量数据库(或其他检索系统)中进行相似度搜索。
  3. 找到最相似的文档片段(Chunk)。
  4. 将这些文档片段与用户问题一起发送给 LLM。
  5. 由 LLM 综合上下文生成最终回答。

整个过程中,RAG 不会回忆过去检索过什么内容,也不会因为之前检索过而记住某些知识。每一次查询都是一次全新的检索过程。

此外,还需要理解一个非常重要的概念:

RAG 检索到的是"相似"的内容,而不一定是真正"相关"的内容。

对于向量检索而言,它依据的是向量空间中的距离(相似度)进行匹配;对于关键词检索而言,它依据的是关键词之间的匹配关系进行搜索。

因此,相似(Similar)并不等于相关(Relevant)

例如,用户询问的是某个算法的实现方式,而检索结果可能只是包含大量相同专业术语的另一篇文章,却并没有真正回答用户的问题。

这也是 RAG 中最核心的问题之一——检索质量(Retrieval Quality)。如果检索阶段没有找到真正有价值的内容,那么即使后续使用再强大的 LLM,也很难生成高质量的回答。因此,RAG 的大量研究工作实际上都是围绕如何提高检索质量、提高召回率以及排序质量展开的。

2. RAG 在整个流程中的位置

理解了 RAG 的作用之后,我们再来看它在整个问答流程中的位置。

在没有使用 RAG 时,流程非常简单,用户的问题会直接发送给 LLM:

1
response = LLM(query)

此时,LLM 只能依赖模型参数中已经学到的知识进行回答。

而加入 RAG 后,流程会多出一个检索步骤。用户的问题首先交给 RAG 系统,由它从知识库中搜索相关文档:

1
relevant_docs = RAG(query)

随后,再将用户问题检索得到的相关文档一起交给 LLM:

1
response = LLM(query, relevant_docs)

整个流程可以表示为:

也正因为如此,RAG 并不是用于替代 LLM,而是作为 LLM 的一个外挂知识系统,在回答问题之前,为模型提供更加丰富、更具时效性的上下文信息,从而提升回答的准确性和可靠性。

3.RAG内部怎么实现呢。

3.1 查询路由(Query Routing)

来自原文:查询重构与分发

当用户的 query 进入 RAG 系统后,首先经过的通常不是检索,而是查询路由(Query Routing)

查询路由可以理解为 RAG 系统中的智能调度中心

如果一个 RAG 系统只连接一个知识库,那么所有查询都可以直接进入检索阶段;但在实际项目中,一个系统往往会接入多个数据源、多个检索器,甚至拥有不同的处理流程。例如:

  • 不同类型的知识库(文档、代码、数据库等)
  • 不同的检索策略(向量检索、关键词检索、混合检索)
  • 不同的 Prompt 模板
  • 不同的 Agent 或工具

这时,并不是所有查询都应该走同一条流程,而是需要先分析用户的意图,再决定最适合的处理路径,这就是查询路由的作用。

简单来说,它负责回答这样一个问题:

"这个问题应该交给谁来处理?"

因此,查询路由本质上是一个分类(Classification)问题。它根据用户问题的语义,将查询分发到最合适的数据源、检索器、处理组件或 Prompt 模板,从而提高系统效率,并提升最终回答的准确性。


两种主流实现方式

目前,查询路由主要有两种实现方式。

方法一:基于 LLM 的意图识别(LLM Routing)

这是目前最常见、也是最灵活的一种方式。

其核心思想是:让 LLM 自己判断用户的问题应该走哪条路线。

开发者只需要在 Prompt 中预先定义好所有可选路由,并说明每条路由适用于什么场景,然后让 LLM 根据用户的问题输出对应的路由标签。

整个流程如下:

  1. 定义所有可选的路由(例如数据源、检索器或 Prompt 模板)。
  2. 编写 Prompt,描述每条路由的适用范围。
  3. 将用户问题发送给 LLM。
  4. LLM 输出对应的路由标签。
  5. 程序根据标签调用相应的检索器或处理链。

这种方式最大的优点是理解能力强、扩展性高。由于 LLM 具备较强的语义理解能力,即使用户的表达方式发生变化,它通常也能够做出正确判断。

当然,它也存在一定缺点:每次查询都需要额外调用一次 LLM,因此会增加一定的响应时间和调用成本。

方法二:基于嵌入相似度的路由(Embedding Routing)

另一种方式是不使用 LLM,而是直接利用向量相似度完成路由选择,因此延迟更低、成本也更低。

它的核心思想是:

将每个路由都表示成一段描述文本,然后比较用户问题与这些描述之间的语义相似度。

整个流程如下:

  1. 为每条路由编写一段能够描述其适用场景的文本。
  2. 使用 Embedding 模型将这些描述转换成向量,并提前保存。
  3. 用户查询到来后,同样计算其向量表示。
  4. 计算查询向量各路由向量之间的相似度。
  5. 选择相似度最高的路由,并执行对应的处理流程。

这种方式无需调用 LLM,因此速度更快,非常适合对实时性要求较高的场景。

不过,它的分类能力通常受限于 Embedding 模型本身以及路由描述的质量,在面对复杂意图时,一般不如 LLM 路由灵活。


本项目使用的是基于 LLM 的意图识别

项目中的食谱知识库支持多种不同类型的问题,例如:

  • list:仅返回菜名列表;
  • detail:查询某道菜的详细制作方法;
  • general:咨询与食谱相关的一般性问题。

不同类型的问题会使用不同的 Prompt 模板以及对应的检索流程。

因此,在真正开始检索之前,系统会首先调用 LLM 对用户问题进行分类,判断其属于 listdetail 还是 general,然后再将查询分发到对应的处理链,最终完成后续的 RAG 检索与回答生成。

3.2 查询重写(Query Transformation)

经过查询路由之后,用户的问题已经被分发到了对应的检索链路。但此时,系统通常不会立即开始检索,而是会先对用户的 query 进行一次查询重写(Query Transformation)(原文中称为查询翻译(Query Translation))。

需要说明的是,查询重写并不一定发生在路由之后,它也可以发生在路由之前。例如,在前面介绍的基于嵌入相似度的路由(Embedding Routing)中,为了获得更加准确的路由结果,也可以先对查询进行重写,再计算其向量表示。

查询重写的目标只有一个:

弥合用户自然语言与知识库文档之间的语义鸿沟(Semantic Gap)。

现实中的用户提问通常存在以下特点:

  • 表达过于简短;
  • 省略了大量上下文;
  • 使用口语化表达;
  • 一个问题中包含多个子问题;
  • 使用知识库中不存在的术语。

而知识库中的文档往往具有更加正式、完整、结构化的表达方式。

如果直接拿原始 Query 去检索,很可能无法找到真正相关的文档。因此,在检索之前,通常会先利用 LLM 对 Query 进行优化,使其更加符合知识库中的表达形式,从而提升召回率和检索准确率。

方法一:基于 Prompt 的查询重写

这是目前最常见,也是实现成本最低的一种方法。

核心思想十分简单:利用 Prompt 引导 LLM 对用户的问题进行重新表达。

根据不同的需求,Prompt 可以设计成多种不同的重写方式,例如:

(1)补充问题细节

用户的问题往往十分简短,而 LLM 可以根据语义补充必要的信息,使 Query 更加完整。

例如:

1
2
3
4
5
用户:
介绍 Transformer

重写后:
请详细介绍 Transformer 模型的基本结构、Self-Attention 机制、Encoder 与 Decoder 的组成,以及它相较于 RNN 的优势。

这样生成的 Query 通常能够匹配到更多相关文档。

(2)查询分解(Query Decomposition)

有些问题实际上包含多个子问题,一次检索很难覆盖全部内容。

例如:

1
Qwen3 和 DeepSeek-R1 有什么区别?各自适用于哪些场景?

可以重写为:

  • Qwen3 的主要特点是什么?
  • DeepSeek-R1 的主要特点是什么?
  • 两者有哪些区别?
  • 两者分别适用于哪些应用场景?

随后分别检索,再合并结果。

这种方法尤其适用于复杂问答(Complex QA)。

(3)查询扩展(Query Expansion)

有时候,用户使用的关键词与知识库中的表达方式并不一致。

例如:

1
微调

可以扩展为:

1
模型微调、Fine-tuning、SFT、Supervised Fine-Tuning、Instruction Tuning

通过增加同义词、近义词、专业术语等关键词,可以提高文档的召回率。

(4)生成结构化查询(Structured Query Generation)

对于结构化数据源,LLM 还可以将用户的自然语言直接转换为数据库能够执行的查询语句,而不是继续进行文本检索。

例如,假设系统连接的是一个 MySQL 数据库,其中存储着商品信息。

用户提出问题:

1
价格低于 100 元且销量最高的耳机有哪些?

LLM 可以先将其转换为对应的 SQL 语句:

1
2
3
4
SELECT *
FROM products
WHERE price < 100
ORDER BY sales DESC;

随后,系统直接执行该 SQL,并返回查询结果。

需要注意的是,这里的检索与 RAG 中常见的向量检索(Vector Retrieval)完全不同。

这里查询的是传统关系型数据库(如 MySQL、PostgreSQL),执行的是 SQL 查询,依赖的是数据库的索引、过滤条件以及排序等能力;而向量检索则是在向量数据库中,根据向量之间的相似度(如余弦相似度、欧氏距离等)寻找语义最接近的文档。

因此,生成结构化查询更适用于具有明确结构的数据源,例如:

  • MySQL、PostgreSQL 等关系型数据库;
  • Neo4j 等图数据库(生成 Cypher 语句);
  • Elasticsearch(生成 DSL 查询);
  • 各类提供 REST API 的业务系统(生成对应的请求参数)。

这种方式本质上属于 Text-to-SQLText-to-Cypher 等自然语言到结构化查询的转换,而不是传统意义上的语义相似度检索。


本项目中,仅对经过 detail 路由的 Query 执行查询重写,所使用的 Prompt 如下图所示。


方法二:HyDE(Hypothetical Document Embeddings)

另一种经典的查询改写方法是 HyDE(Hypothetical Document Embeddings,假设性文档嵌入)

HyDE 由 Luyu Gao 等人在论文中提出,是一种无需微调模型即可提升向量检索效果的方法。

它主要解决的是向量检索中的一个典型问题:

用户的 Query 往往非常短,而知识库中的文档却十分完整,两者在向量空间中的语义分布存在较大的差距。

例如,用户可能只输入一句:

Transformer 为什么比 RNN 好?

但知识库中的文档却可能包含数百甚至数千字,对 Transformer 的背景、结构、优势等进行了完整介绍。

直接使用这句 Query 计算向量,往往无法准确定位到真正相关的文档。

HyDE 的思路非常巧妙:

不直接使用 Query 去检索,而是先让 LLM 生成一篇"假设已经回答了该问题"的文档,然后再利用这篇假设文档进行向量检索。

这样,就把原本困难的:

Query → Document

匹配问题,

转化为了更加容易的:

Document → Document

匹配问题。

由于两侧都是完整的文档,因此向量空间中的语义分布更加接近,检索效果通常会更好。

HyDE 的整体流程如下:

  1. 生成(Generate)

    将用户 Query 输入给生成式 LLM,让模型生成一篇能够回答该问题的"假设性文档"。这篇文档不要求完全真实,但必须与理想答案保持较高的语义一致性。

  2. 编码(Embed)

    使用 Embedding 模型对这篇假设文档进行向量化,得到其语义表示。

  3. 检索(Retrieve)

    使用该向量在向量数据库中进行相似度搜索,找到与这篇假设文档最接近的真实文档。

最终,系统将检索到的真实文档作为 RAG 的上下文,交给后续的 LLM 生成回答。

相比直接对 Query 进行向量检索,HyDE 往往能够获得更高的召回率,尤其适用于用户问题较短、语义信息不足的场景。不过,它也需要额外调用一次 LLM 来生成假设文档,因此会带来一定的时间和计算成本。在实际项目中,需要根据检索效果与系统性能之间的权衡决定是否采用。

3.3 构建上下文并生成回答

经过检索后,搜索引擎会返回与用户问题最相关的 Top-K 个结果。这些结果可以是文档片段(Chunk),也可以是完整文档(Document),具体取决于知识库的组织方式以及检索策略。

随后,RAG 会将这些检索结果作为上下文(Context),与用户的原始问题共同构造成 Prompt,并发送给 LLM,由模型生成最终回答。

如下图所示:

从整个流程可以看出,RAG 并不负责生成答案,它的职责只是从外部知识库中检索出与问题相关的信息,并将这些信息作为上下文提供给 LLM。真正完成理解、推理以及回答生成工作的,始终是大语言模型本身。

因此,可以将 RAG 理解为 LLM 的一个外部知识提供者。它通过动态检索外部知识,弥补模型参数中知识有限、知识过时等问题,使模型能够基于最新、最相关的信息生成更加准确、更加可靠的回答。

当然,检索返回的文档数量(Top-K)也是一个需要权衡的参数。K 值过小,可能遗漏关键知识,导致回答信息不足;K 值过大,则可能引入大量无关内容,不仅增加 Token 消耗,还可能使模型受到无关上下文的干扰。因此,在实际项目中,通常需要结合知识库规模、文档长度以及模型上下文窗口大小,对 Top-K 参数进行调优。


本项目中的回答生成

前面介绍查询路由时提到,本项目将用户问题划分为三种类型:

  • list:菜谱列表查询;
  • detail:菜谱详情查询;
  • general:一般性问答。

不同类型的问题,在获得检索结果后,会采用不同的处理方式。

(1)List 模式

对于 list 类型的问题,并不需要 LLM 参与生成。

系统会直接从检索结果中提取符合条件的菜名,并组成列表返回给用户。这种方式不仅响应速度更快,还能够避免 LLM 在生成过程中产生幻觉(Hallucination)。

如下图所示:

(2)Detail 模式

对于 detail 类型的问题,则需要将检索得到的相关文档填充到对应的 Prompt 模板中,再交由 LLM 生成回答。

为了保证回答格式统一、内容完整,本项目采用了结构化 Prompt,对回答内容进行了明确约束,例如要求模型按照菜品介绍、所需食材、制作步骤、注意事项等固定结构进行输出,从而提升回答的规范性、可读性以及实用性。

对应的 Prompt 模板如下:

(3)General 模式

对于 general 类型的问题,其处理方式与 detail 基本一致,同样会将检索得到的上下文填充到对应的 Prompt 模板中,再交由 LLM 进行回答生成。不同之处在于,两者使用的 Prompt 模板不同:detail 更侧重于生成结构化、步骤清晰的菜谱内容,而 general 则更适用于开放式的食谱相关问答。

4. 小结

本章节主要介绍了 RAG 的基本概念、整体工作流程,以及检索引擎之外的关键技术,包括查询路由(Query Routing)、查询重写(Query Transformation)和回答生成等内容。

通过这些模块可以看到,RAG 并不仅仅是"检索 + LLM"这么简单,而是由多个环节协同工作,共同提升最终回答的准确性与可靠性。

下一章节将聚焦于 RAG 检索引擎 本身,详细介绍其内部实现,包括文档切分、向量化、向量数据库、检索策略等核心技术,进一步理解 RAG 是如何从知识库中高效、准确地检索出相关信息的。