老胡茶室
老胡茶室

无废话 RAG:理论篇

胡键

看过星爷《鹿鼎记》的同学应该都记得影片开头韦小宝青楼说书的那段:“平生不识陈近南,便称英雄也枉然”。在如今的 AI 界,“陈近南”便是 RAG(Retrieval-Augmented Generation)。论江湖地位,RAG 相当于 AI 时代的 CRUD。

虽然人人都说 CRUD 简单,谈话间还略带轻视或戏虐。但现实是,这些人们口中简单的 CRUD 应用却在生产环境中问题频发,成为不少开发者噩梦的开始。在如今的 AI 时代,这类现象依旧存在,只是这次的主角换成了 RAG。

于是,我打算在这次的无废话文章中,聊聊 RAG,同时分享一下我们在实际项目中使用 RAG 的经验。

这同样是一篇付费文章,并且我同样会列出相关的大纲供动手能力强的同学自行探索和学习:

  • 以下内容,可自行问 AI 或搜索
    • RAG 的 what 和 why
    • RAG 的架构和组件
    • RAG 的两个阶段:indexing 和 querying
  • 主流框架的文档几乎都有 RAG 的示例代码
  • 自行专研典型 RAG 的场景,包括但不限于:Agentic、Hybrid Search、CAG、权限和 Filter 等。
  • 最后,再去了解另一个热门的扩展概念:Knowledge Graph。

按此大纲学习,你基本上不会遗漏 RAG 的要点。至于细节上了解到什么程度,就看你自己了。

个人建议在学习过程中结合 Deep Research,按它的报告按图索骥,同时辅以框架代码实战找找感觉。如此往复,迟早神功可成!

初识 RAG

What 和 Why

RAG 是 Retrieval-Augmented Generation 的缩写,意为“检索增强生成”,其核心目的是通过检索外部知识来增强生成模型的能力,减少模型的 hallucination(幻觉)。

How

最简单的 RAG 的工作流程如下:

  1. 根据用户的输入查询外部知识库或文档。
  2. 将结果与系统 prompt 模版合并,生成最终的系统 prompt。以我之前写的 TSW 插件为例,其生成函数如下。其中的 context 为 1 的结果。
const pageRagPrompt = (context: string) => {
  return `
  You are a helpful assistant and can do the following tasks:
  1. answering users's question based on the given context.
  2. finding relevant information based on the input.

  Workflow:
  1. try to answer the question based on the context.
  2. if the context is not sufficient, ask the user if he/she wants to search on web.
  3. if the user agrees, search the web and provide the answer. Don't try to answer the question without searching.

  Try to keep the answer concise and relevant to the context without providing unnecessary information and explanations.
  If you don't know how answer, just respond "I could not find the answer based on the context you provided."

  Page Context:
  ${context}
  `;
};
  1. 将最终系统 prompt 和用户输入一并传给 LLM 模型,生成最终的响应。

但是,以上流程只挑明了 RAG 的一半,即 querying 阶段。对于另一半,即知识库的如何建立却只字未提。

RAG 应用的一般性架构和关键组件

下图较好地展示了 RAG 的架构和关键组件,并且清晰地划分了 indexing (左侧)和 querying (右侧)两个阶段。

RAG 架构 【来源:Best Practices in Retrieval Augmented Generation

其中的关键组件和发挥作用的阶段:

  • indexing 阶段:创建知识库
    • 文档:原始数据源,可能是文本、图片或其他格式。
    • Data Loader:负载加载文档。
    • Data Splitter:将文档分割成更小的片段,以便于后续处理。
  • querying 阶段:使用知识库
    • LLM:大语言模型
  • 两个阶段共用:
    • Embedding Model:将字符串转换为向量表示。
    • Vector DB:存储向量和文档数据,它就是知识库。

这里对于初学者不太熟悉的恐怕是 Embedding Model 和 Vector DB。因此,在进入下一节之前,先花点时间稍作解释。

首先聊聊 Embedding Model。其核心操作就是 embedding。这一术语是一个数学概念,类似高中数学课本中的“映射”,其作用是将字符串映射到向量空间中的一个向量,完成字符串到向量的转换,为后续的向量检索打基础。

在此,常见的问题有:

  1. 为何不直接比较字符串,而非要将字符串转换为向量?
    • 答:这里有准确性和效率两个方面的考虑。直接比较字符串的准确性较低,尤其是对于语义相似但表述不同的字符串。而向量表示可以捕捉到更深层次的语义信息,从而提高检索的准确性。同时,向量检索在大规模数据集上比字符串检索更高效。
  2. 如何完成字符串到向量的转换?
    • 答:通过 Embedding Model 完成,它是一个预训练的模型,可以字符串到向量的转换,同时保持语义信息。比如:“法国”和“巴黎”两个字符串的向量表示在向量空间中会非常接近,因为它们在语义上是相关的。

对于有一定基础的同学,可能还是问:不是已经有 one-hot encoding 了吗?何必再搞个 embedding 呢?这里面同样有两个方面的考虑:

  1. 效率,one-hot encoding 会导致最终向量表示的维度过高,因为只有一位是 1;而 embedding 则是稠密向量表示。不仅节约了空间,同时也提升了计算效率。
  2. 语义,one-hot encoding 无法捕捉到字符串之间的语义关系,而 embedding 可以。

知道 Embedding Model 后,Vector DB 的作用就一目了然了:存储向量和文档数据,并提供高效的向量检索功能。这里有一点需要注意:除了向量,你还需要将原始字符串存储在 Vector DB 中。这样做的目的是在检索到相关向量后,能够快速找到对应的原始字符串,它才是你真正需要的数据,即 context

了解完这些背景知识,我们就可以开始深入了解 RAG 的两个阶段了。

RAG 的两个阶段

除非你的 RAG 应用是直接利用互联网上现成的数据,否则你一定绕不开知识库的建设。尤其是在搭建企业自己的 RAG 系统时,知识库的建设更是必不可少。这一点不言自明:

  1. 并非所有的企业内部数据都适合公开放在互联网上。
  2. 凡是企业应用,也一定绕不开权限控制,而这些信息需要内嵌在知识库中。

于是,典型的 RAG 系统通常会经历两个阶段:indexing 和 querying。前者是创建知识库,后者是使用知识库。

Indexing 阶段

一个典型的 indexing pipeline 如上图中 A-D 部分。以 Langchain 为例,其中涉及的关键组件如下:

  • DocumentLoader,加载文档数据。
  • TextSplitter,将文档分割成更小的片段。
  • Embedding Model,将字符串转换为向量表示。
  • VectorStore,将向量和原始文档存储起来。

DocumentLoader

文档可以有多种形式,也可能来自不同的数据源。通过 Langchain 提供的 DocumentLoader,你可以统一实现文档加载。你可以从其官方文档了解它目前支持的数据源和格式,如果碰巧你的文档不在其支持之列,你也可以通过继承 BaseDocumentLoader 来实现自己的 DocumentLoader。

基于我们的实际经验,你可能需要注意的地方:

  • js 版本和 python 版本有些许不同,所以在实际使用时请仔细核对文档。
  • 加载某个网站的内容时,请注意访问的深度。
  • 加载 GitHub 仓库时,即使是公共仓库,也请使用自己的 GITHUB_TOKEN,以免触发 GitHub 的访问限制。
  • 对于 PDF 文档,如果你使用的是 langchain.js,那么建议使用 mupdf 实现自己的 PDF Loader。因为 LangChain.js 的 PDF Loader 有点过时,关于这一点,我在《无废话 LangChain.js》 中已经提过,这里就不再赘述。
  • 对于企业中常见的 Excel 格式,langchain.js 没有提供直接的支持,我们实现了自己的版本,用的是 read-excel-file。同时注意,它支持的是 .xlsx 而非旧的 .xls 格式。但这一点问题不大,因为 .xlsx 是目前的主流格式。

最后,建议在进一步处理前对文档内容进行清洗。一个简单的实现如下:

export function sanitizeText(text: string) {
  if (!text) return "";

  return (
    text
      // Remove C0 and C1 control characters and DEL, but preserve newlines (\n, \r)
      // biome-ignore lint/suspicious/noControlCharactersInRegex: <explanation>
      .replace(/[\u0000-\u0009\u000B-\u000C\u000E-\u001F\u007F-\u009F]/g, "")
      // Replace multiple newlines (3 or more) with double newlines
      .replace(/(\n\s*){3,}/g, "\n\n")
      // Replace multiple spaces with a single space
      .replace(/[ \t]+/g, " ")
      .trim()
  );
}

TextSplitter

文档加载完之后,接下来就是所谓的 Chunking,即切块。这一步之所以的存在的原因有以下几点:

  • LLM 的 context window 限制。
  • 效率和准确性,这里有两方面。
    • 首先是 indexing 阶段,字符串越长,embedding 所需时间越长;同时,长向量也会导致存储不便。
    • 其次是 querying 阶段,用户的输入往往是文档中很小的片段,若是不进行分块,不仅浪费,并且结果也不会太好。
  • 保证语义的完整性,相关的内容被分在同一个块中,这样不仅有助于检索,同时也有助于生成更准确的回答。

对于 chunking,需要注意的地方:

  • 分块大小没有统一的标准,需要结合实际场景来调整。过大,则可能引入噪音;过小,则可能导致语义不完整。
  • 分块之间需要保持一定的重叠,其大小同样需要实验来确定。它之所以必要,是为了避免分块过程中可能出现的语义割裂。比如:我买了一辆雷克萨斯。
    • 假如没有重叠,且分块大小为 6 个字,结果为:[“我买了一辆雷”, “克萨斯”]。显然,第一个分块让人觉得有点奇怪,但似乎也都是正确的。
    • 若有重叠,且重叠大小为 3 个字,则结果为:[“我买了一辆雷”, “一辆雷克萨斯”]。这样就避免了语义割裂的问题。
  • langchain 中提供了多种 TextSplitter,假设你不太清楚如果选择,那么先从 RecursiveCharacterTextSplitter 开始。大多数情况下不会有问题,对于带格式类的文档基本适用,如代码文件或者 MD 文档。事实上,MarkdownTextSplitter 就是从它继承而来。
  • 如果需要处理的文档很小,比如几组简单的句子或则几段话,那么可以直接使用 CharacterTextSplitter

至于更高级的分块策略和方式,可以阅读《5 Levels Of Text Splitting》

同时在 langchain 的 python 版本文档中提供了如何使用语义相似度进行分块的例子

Embedding Model

在使用 Embedding Model 将字符串转换为向量时,有几点需要考虑:

  • 如果应用需要支持多语言环境,请确保所选的 Embedding Model 支持多语言,同时支持你需要的语言。
  • 如果你的应用没有特殊的需求,那么使用现成的通用模型就足够了。
  • 反之,可能需要针对特定领域或行业进行微调,但这里会带来额外的成本和复杂性。如:数据收集的成本、模型微调的成本,以及最关键的时间成本。同时,训练数据的质量和数量也会直接影响模型的效果。
  • 此外,向量维度的选择也很重要。与分块大小的选择类似,它也是一个实验过程,并且它的大小与分块大小是相关的。如果不知道该怎么选择,那就从模型的缺省大小开始。
  • 同时,每个模型的缺省向量维度并不相同,建议即使使用缺省值,也要明确指定向量维度,避免混淆。

如果你特别在乎数据的隐私和安全,则可以考虑本地部署模型,当然这会带来额外的成本和复杂性。比如:需要考虑模型的部署、维护和更新等问题。

如果这都不是问题,那么可以考虑从 HF 的 Transformers 库开始。对于 js,则可以使用 Transformers.js。

VectorStore

在 pipeline 的最后一步,向量和文档数据会被存储到 VectorStore 中。与关系数据库类似,你也面临着几个问题:

  1. 选择哪种向量数据库?
  2. 如何设计向量数据库的 schema?
  3. 向量的维度是多少?
  4. 数据索引如何设计并选择哪种索引类型?
  5. 如何处理向量数据的更新?

面临如今玲琅满目的向量数据库,我建议先从 pgvector 开始。原因在于:

  • 你大概率会在项目中用到 PG,而 pgvector 是引入向量数据库的最简单方式,且它能与你存储在 PG 中的数据无缝集成。
  • schema 设计和索引设计都与 PG 相似,且可以和现有的工具链,如 drizzle 等无缝集成。

关于 schema 设计,在使用 langchain.js 时,它会自动创建相关的表 documents,包含四个字段:

  • id,主键,UUID。
  • content,text,存储原始文档内容。
  • metadata,jsonb,存储文档的元数据。
  • embedding,存储文档的向量表示。

关于索引设计,与 PG 相似。你需要了解的是索引类型,关于这一点,可参见《向量数据库索引:综合指南》。如果不确定,先从 HNSW 开始。

关于向量数据的更新,langchain.js 也提供了 RecordManager 来帮助你进行增量更新,避免每次重新计算。它是通过在数据库中维护一个记录表 upsertion_records 来实现的。同样的,这张表也会被自动创建,包含字段如下:

  • id,主键,UUID,它有 chunk 的 content 和 metadata 的哈希值生成。
  • key,它指向 documents.id
  • namespace,由你指定,比如,你可以使用原始文档的路径名或 URI。
  • updated_at,更新时间。
  • group_id,可以与 namespace 一样。

其中,(key, namespace) 代表了文档中唯一的一个分块。

至此,indexing 阶段的相关技术细节和设计考虑都已经介绍完毕。在向量数据库部分尚未介绍的相似度内容,则放在下一节介绍。

Querying 阶段

上图中 1-5 部分代表了 querying 阶段,它使用知识库来生成最终响应。虽然在 How 部分已经简单介绍了 querying 的过程,但并未深究其技术细节。在这里,你将找到它们。

在 querying 阶段,主要涉及以下几个关键组件:

  • Embedding Model:见上,略。
  • VectorStore:见上,略。
  • Retriever:负责从 VectorStore 中检索相关的文档。
  • Reranker:负责对检索到的文档进行重新排序,以提高最终结果的相关性。
  • LLM:见上,略。

Embedding Model

上文已经说过,我们比较的并不是字符串,而是它们的向量表示,因而在 querying 阶段,仍然需要用到 Embedding Model。它的作用是将用户的输入转换为向量表示,以便于与 VectorStore 中存储的向量进行比较。

这里唯一需要注意的便是:使用与 indexing 阶段相同的 Embedding Model,以保证向量表示的一致性。

VectorStore

在 indexing 阶段,我们已经介绍了 VectorStore 的存储细节,在此阶段,让我们来看看它的检索机制。

首先,检索向量并不是基于如数值类型那样的精确匹配而是基于所谓的相似度来完成。

其次,关于向量相似度的计算,存在多种方式,且各有各的适用场景。限于本文的篇幅和主题,在此不再展开,有兴趣的同学可自行与 AI 对答或搜索相关资料。

对于 RAG 场景,最常用的相似度计算方式是余弦相似度(Cosine Similarity)。它通过计算两向量间的夹角的余弦值来衡量它们的相似度。

  • 余弦值越接近 1,表示两向量越相似;
  • 反之,越接近 -1,则表示两向量越不相似。
  • 余弦值为 0,则表示两向量正交,即没有相似度。

它只关注方向,不关注大小(幅度):即使两个向量的长度差异很大,只要它们指向同一个方向,余弦相似度也会很高。在 pgvector 中,余弦相似度的计算方式如下,即 (1 - 余弦距离):

SELECT 1 - (embedding <=> '[3,1,2]') AS cosine_similarity FROM items;

其中的 <=> 表示余弦距离。

最后请注意,因为是相似度计算,因为你需要控制返回的结果数量,不要忘了使用 LIMIT

Retriever

实际的检索过程有 retriever 来完成。Langchain.js 提供了多种 retriever 实现,就连 VectorStore 都提供了 asRetriever 方法来将 VectorStore 转换为 retriever。

检索看似简单,但实现上却有多种技术选择:

  • 从 VectorStore 角度,以 pgvector 为例,至少有两种方式:
    • 仅使用向量检索。
    • 结合关键字进行混合检索(Hybrid Search),其中关键字检索需结合 PG 的全文检索功能。同时,对于多语言支持需要添加额外的包。
  • 从 LLM 角度,也至少可以有两种方式:
    • 直接使用 LLM model 的朴素 RAG。
    • 使用 agent + retrieve tool 的 Agentic RAG。

这里直接说结论:直接使用 Hybrid Search + Agentic RAG。原因:

  • Hybrid Search 可以以较低成本实现更高的检索准确性。
  • Agentic RAG 则可以给 LLM 更大的灵活性和控制力,它可以自行决定以下工作:
    • 是否需要优化 query,因为有可能用户的输入并不专业,不适于直接使用。
    • 是否需要使用 retrieve tool

并且,引入 Agentic RAG 后,为后续更复杂的场景打下了基础。典型场景:

  • Deep Research
  • 结合 Web Search Tool
  • 结合其他工具或者外部 API,尤其是现在 MCP 大流行的背景下

在实现实际搜索时,我们并没有直接使用 Langchain.js 的 retriever 类,而是直接使用 SQL 来实现了一个 Hybrid Search 的 retrieve tool 传入给 Agent 使用。原因在于:

  • 我们需要更精细的控制
  • 我们的 SQL 技能足以支撑接下来的实现

各位可自行判断自己的实现路线。

Reranker

一个有意思的现象是,不少介绍 RAG 的文章对于 Reranker 着墨并不多,甚至只字未提。但它却是一个优秀 RAG 实现必不可少的组件。

并且从名字上看,Reranker 的作用也不是一目了然,这就导致很多人一开始就忽视了它,比如我自己。Reranker 的作用是对检索到的文档进行重新排序,以提高最终结果的相关性。

或许你会问:检索时不就已经基于相似度排序了吗?这难道还不够吗?答案是:不够。

向量相似度虽然能捕捉语义,但其计算通常基于独立表示的向量,可能无法充分捕捉查询与文档之间的复杂交互关系。例如,两个文档可能都包含查询中的关键词,但其中一个文档对查询的直接回答性更强,而向量相似度可能无法区分这种细微差别。

Reranker 的存在就是为了弥补这些局限性,提升排序的准确性和相关性。它相当于是查询结果的后处理。

与 Embedding Model 类似,Reranker 也是一个预训练模型,主流的 LLM Provider 都提供了相应的 API 可供调用。比如,在我们的老胡茶室中使用的就是 Google Vertex AI 的 semantic-ranker-default@latest。关于如何进行配置,请参见《图文教程:配置 Google Cloud Vertex AI 与 Discovery Engine API 并排错》

总结

一开始规划此文时,我原本打算通过一篇文章来覆盖 RAG 的理论和实践。结果在写的过程中越写越长,于是乎就决定将其拆分为两篇。这样更有利于读者的理解和消化。

通过理论篇的学习,我自信应该已经让你对 RAG 有了一个全面的了解,也没有落下关键的技术信息和设计考虑,除非是我们目前尚未接触到的。在接下来的实践篇中,我们将通过实际的代码示例来展示如何在实际项目中使用 RAG。

付费内容

本文包含付费内容,需要会员权限才能查看完整内容。