基础概念

  1. 什么是RAG

    RAG(Retrieval-Augmented Generation,检索增强生成)简单来说就是给LLM提供一个外部知识库,当用户提问的时候,通过信息检索技术从外部知识库中检索出和用户问题相关的信息,然后让 LLM 结合这些相关信息来生成结果。

  2. 优点

    1)提升对长尾知识的回答效果

    LLM 对长尾知识的学习能力比较弱,生成的回复通常并不可靠。因为LLM训练数据的涵盖范围有限,让LLM在有限的参数中记住所有知识或者信息是不现实的,总会有一些长尾知识在训练数据中不能覆盖到。

    为了提升 LLM 对长尾知识的学习能力:

    • 在训练数据加入更多的相关长尾知识,或者增大模型的参数量,虽然这两种方法确实都有一定的效果,但这两种方法是不经济的,需要一个很大的训练数据量级和模型参数才能大幅度提升 LLM 对长尾知识的回复准确性。
    • RAG:通过检索的方法把相关信息在 LLM 推断时作为上下文给出,既能达到一个比较好的回复准确性,也是一种比较经济的方式。

    2)私有数据

    • 目前通用的 LLM 预训练阶段利用的大部分都是公开的数据,不包含私有数据,因此对于一些私有领域知识是欠缺的。比如问 LLM 某个企业内部相关的知识,LLM 大概率是不知道或者胡编乱造。如果我们采用私有数据微调或者在预训练阶段就加上私有数据的方法,训练和迭代成本很高。
    • 此外,通过一些特定的攻击手法,可以让 LLM 泄漏训练数据,如果训练数据中包含一些私有信息,就很可能会发生隐私信息泄露。

    RAG的好处在于:将私有数据作为一个外部数据库,让 LLM 在回答基于私有数据的问题时,直接从外部数据库中检索出相关信息,再结合检索出的相关信息进行回答。这样就不用通过预训练或者微调的方法让 LLM 在参数中记住私有知识,既节省了训练或者微调成本,也一定程度上避免了私有数据的泄露风险。

    3)数据实时更新

    因为有一些知识或者信息更新得很频繁,LLM 通过从预训练数据中学到的这部分信息就很容易过时。如果把频繁更新的知识作为外部数据库,让 LLM 在回答的时候进行检索,就可以实现在不重新训练 LLM 的情况下对它的的知识进行更新和拓展,从而解决 LLM 数据新鲜度的问题

    4)结果的可解释性

    通常情况下,LLM 生成的输出不会给出其来源,很难解释LLM为什么会这么回答,基于什么信息生成的回答。而RAG的思想就可以给出LLM生成回复的参考来源,增强了可解释性。

    典型的一个通过检索来增强输出的例子就是 Bing Chat,其生成的回复中会给出相关信息的链接:

    image-20250418111338703

关键模块

RAG 需要实现的关键模块,和对应模块需要解决的问题包括:

  • 数据和索引模块:如何处理外部数据和构建索引
  • 查询和检索模块:如何准确高效地检索出相关信息
  • 响应生成模块:如何利用检索出的相关信息来增强 LLM 的输出

数据和索引模块

数据获取

  1. 数据获取模块的主要作用在于:将多种来源、多种类型和格式的外部数据转换成一个统一的文档对象( Document Object ) 。文档对象包括原始文本内容 + 文档的元信息 ( Metadata )

    • 元信息:通过NLP技术(实体识别、文本分类等)或 LLM 实现提取
      • 文本总结和摘要
      • 时间信息,比如文档创建和修改时间
      • 标题、关键词、实体(人物、地点等)、文本类别等信息
  2. 外部数据

    • 来源:聊天数据、Github托管代码文件、Web网页内容、各种文档

    • 格式:纯文本、代码、文档、图片、音频、视频等

文本分块

  1. 为什么要进行文本分块

    因为 LLM 的上下文长度限制,直接把一篇长文全部作为相关信息放到 LLM 的上下文窗口中,可能会超过长度限制。另一方面,一般查询到的文章不会和用户提问完全相关,分块能一定程度上剔除不相关的内容,为后续的回复生成过滤一些不必要的噪声。

  2. 设计分块策略时需要考虑的因素

    • 原始内容的特点:原始内容是长文 ( 博客文章、书籍等 ) 还是短文 ( 推文、即时消息等 ),是什么格式 ( HTML、Markdown、Code 还是 LaTeX 等 ),不同类型内容适用于不同的分块策略。

      比如针对长文,我们可以将它分割成较大的文本块,便于LLM整体理解和处理。如果是短文,则通常不需要大幅度分块,因为它本身较为简短,处理时不容易超出输入限制。

      代码可能需要按照功能进行分块,而HTML文档则可能基于标签结构进行分块。

    • 结合后续使用的索引方法:目前最常用的索引是对分块后的内容进行向量索引,在设计分块策略时,需要考虑所使用的向量嵌入模型对分块大小的要求。

      比如 sentence-transformer 模型比较适合对句子级别的内容进行嵌入,OpenAI 的 text-embedding-ada-002 模型比较适合的分块大小在 256~512 个token;

    • 检索出的相关内容在回复生成阶段的使用方法:如果是直接把检索出的相关内容作为 Prompt 的一部分提供给 LLM,那么 LLM 的输入长度限制在设计分块大小时就需要考虑。

    • 问题的长度:问题的长度需要考虑,因为需要基于问题去检索出相关的文本片段,如果问题较长且复杂,检索到的内容也可能会比较多。

  3. 分块实现方法

    1)将原始的长文本切分成小的语义单元(比如句子级别或者段落级别)

    常用方法:定义一组分割符进行迭代切分(比如定义 ["\n\n", "\n", " ", ""] 这样一组分隔符),切分的时候先利用第一个分割符进行切分 ( 实现类似按段落切分的效果 ),第一次切分完成后,对于超过预设大小的块,继续使用后面的分割符进行切分,依此类推。这种切分方法能比较好地保持原始文本的层次结构。

    • 对于一些结构化的文本,比如代码,Markdown等文本,在进行切分的时候要注意保证结构的完整性和一致性:

      • 比如 Python 代码文件通常包含多个函数、类、变量等代码单元,这些单元之间的逻辑关系非常重要。简单地按行分割可能会破坏代码的语法和功能,因此在切分时需要特别关注代码块的边界。

        在分块时,以Python关键字(如classdef等)作为切分符号,在分割符中加入类似 \nclass \ndef 这种来保证类和函数代码块的完整性;

      • 比如 Markdown 文件,是使用#符号来表示不同层级的标题,在切分时就可以根据标题的层级来决定分块的大小。

    2)将这些小的语义单元融合成更大的文本块,直到达到设定的块大小 ( Chunk Size ),就将该块作为独立的文本片段。

    ① 文本块大小的影响

    • 太大:可能超出模型的最大输入长度限制,导致模型在生成回复时处理起来过于复杂,从而响应变慢。同时,过大的文本块可能会包含不相关的信息,降低生成内容的准确性。
    • 太小:可能会使模型更难抓住长文本中的整体语义,导致生成的内容片段较为片面。

    ② 文本块大小的计算方法

    • 基于字符数

      将文本按照字符数来分割。例如,设定一个文本块最大字符数为1000个字符,那么每当文本达到或超过1000个字符时,就会切分为一个新的文本块。

    • 基于token数

      基于token数计算块大小,LLM都是基于token来处理文本,这样更符合模型处理方式。

      标记(Token)是语言模型处理文本的最小单位,通常包括词汇、符号、标点符号等。每个标记可以代表一个词或部分词。例如,在英语中,“I’m”可能被分为两个标记(I 和 ’m),而在中文中,一个词就可能是一个标记。

    ③ 如何确定文本块的大小

    根据不同的场景来选择,比如在处理技术文档时,较大的块可能更有利于捕捉全局信息;在处理新闻摘要时,较小的文本块可能更有利于抓住核心内容。实际操作中,可以通过反复测试不同的分块大小,评估模型生成的回复是否符合要求,最终选择最适合的块大小。

    3)迭代构建下一个文本片段,一般相邻的文本片段之间会设置重叠,以保持语义的连贯性。

    为了确保相邻块之间的语义连贯性,可以设计重叠部分。这样做有助于保持信息的连续性,尤其是在跨越文本块的边界时,避免信息丢失或断裂。通常重叠部分会是相邻块的最后几句话或段落

    例如,我们希望在块之间有1个句子重叠,那么:

    • 块1:句子1 + 句子2 + 句子3

      “今天的天气真好,阳光明媚,气温适宜。大家都在公园里散步,享受着美好的天气。孩子们在草地上玩耍,欢声笑语不绝于耳。”

    • 块2:句子3 + 句子4 + 句子5

      “孩子们在草地上玩耍,欢声笑语不绝于耳。人们脸上洋溢着幸福的微笑,仿佛所有的疲劳都被阳光驱散了。公园内的花草也开始慢慢盛开,春天的气息充满了整个城市。”

    通过设置重叠,句子3在两个块中都出现,确保了语义的一致性,并且在跨块时,信息不会中断。

数据索引

在这一模块对处理好的数据进行索引,用于快速检索出与用户查询相关的文本内容

常见的索引结构

链式索引

  1. 原理:通过链表将多个文本块(如文档、段落、句子等)链接在一起,形成一个顺序的链条。每个链表节点包含一个文本块的内容,以及指向下一个节点的指针。

    image-20250418170333710

  2. 检索方法

    1)顺序遍历:通过简单地遍历链表中的所有节点来顺序访问文本块

    2)基于关键词过滤:节点存储文本块相关元数据(如关键词、标签),检索的时候使用关键词对链表进行过滤(遍历链表中的每个节点,查找包含指定关键词的文本块,然后返回这些节点)。

树索引

  1. 原理:将一组文本块(节点)自下而上地构建树形结构。树的每个节点存储一个文本块或该文本块的摘要,每个父节点通常是子节点内容的摘要或概括。树的根节点通常包含整个文本集合或整个文档的汇总信息。

    image-20250418171346838

  2. 检索方法

    • 从根节点向下进行遍历。根据查询的关键词逐层深入到叶节点,定位相关的文本块
    • 基于根节点信息进行检索。在某些情况下,直接访问根节点的摘要信息即可获得足够的上下文信息,进而避免了对所有子节点的详细查询。
  3. 具体实现

关键词表索引

  1. 原理:提取每个文本块节点地关键词,建立一个关键词到节点的映射表。支持多对多的关系,即每个关键词可以指向多个节点,每个节点也可以包含多个关键词。

    image-20250418171722561

  2. 检索方法

    1) 查询关键词匹配

    用户输入的查询会被分词并与关键词表中的条目进行匹配。每个查询关键词都会在关键词表中查找(倒排查询),找到包含该关键词的所有节点。

    2) 多个关键词匹)

    • 交集查询:如果查询中包含多个关键词,检索过程通常需要将这些关键词对应的节点集合进行交集操作,得到同时包含所有查询关键词的节点。这一步骤大大缩小了检索范围,提高了查询的精度。

    • 相关性排序:对于查询中包含多个关键词的情况,关键词表索引还可以根据每个节点中关键词的出现频率或其他相关性指标,对节点进行排序,返回最相关的结果。

向量索引(最流行)

  1. 原理

    这种方法一般利用文本嵌入模型 ( Text Embedding Model ) 将文本块映射成一个固定长度的向量,然后存储在向量数据库中。检索的时候,对用户查询文本采用同样的文本嵌入模型映射成向量,然后基于向量相似度计算获取最相似的一个或者多个节点。

  2. 文本嵌入模型

    有很多现成的文本嵌入模型:

    • 早期的 Word2Vec、GloVe 模型等,目前很少用。
    • 基于孪生 BERT 网络预训练得到的 Sentence Transformers 模型,对句子的嵌入效果比较好
    • OpenAI 提供的 text-embedding-ada-002 模型,嵌入效果表现不错,且可以处理最大 8191 标记长度的文本
    • Instructor 模型,这是一个经过指令微调的文本嵌入模型,可以根据任务(例如分类、检索、聚类、文本评估等)和领域(例如科学、金融等),提供任务指令而生成相对定制化的文本嵌入向量,无需进行任何微调
    • BGE模型: 由智源研究院开源的中英文语义向量模型,目前在MTEB中英文榜单都排在第一位。

    这些现成的文本嵌入模型没有针对特定的下游任务进行微调,所以不一定在下游任务上有足够好的表现,最好的方式一般是在下游特定的数据上重新训练或者微调自己的文本嵌入模型。

  3. 相似向量检索

    • 相似性度量:通常情况下可以直接使用余弦相似度,也可以采用点积、欧式距离等方法
    • 相似向量检索:使用向量数据库 Faiss
  4. 向量数据库选型

    • Milvus :分布式架构,性能高(支持多种索引类型),支持多模态数据类型,适合大规模数据

    • Faiss:灵活(可以很方便地集成),支持多种索引类型,支持GPU加速,可以实现快速检索(如IVF、PQ、HNSW 等)。但是本质上还是一个库,而不是完整的数据库系统,缺乏数据管理等功能

    • Weaviate:支持多模态数据类型,支持混合检索(结合传统的关键词搜索和向量搜索),检索效果好。但是不持支GPU加速

      • 低延迟:适合实时应用场景。

      • 适用场景:复杂查询场景,如智能问答、知识图谱等。

    特性/数据库 Milvus Faiss Weaviate Qdrant
    类型 开源分布式数据库 开源库(非数据库) 开源数据库 开源数据库
    部署方式 本地部署/云托管 本地部署 本地部署/云托管 本地部署/云托管
    扩展性 高(支持水平扩展) 受限(无内建分布式支持) 中(静态分片) 高(支持水平扩展)
    性能 索引构建速度快,查询延迟低 高效,支持GPU加速 支持多模态检索,延迟较高 查询吞吐量高,延迟低
    多模态支持 支持(文本、图像等) 受限(主要为文本向量) 强(内建图像、文本等支持) 支持(文本、图像等)
    混合检索 支持(关键词 + 向量) 不支持 支持(关键词 + 向量) 支持(关键词 + 向量)
    GPU加速 支持(NVIDIA CUDA) 支持(NVIDIA CUDA) 不支持 不支持
    社区活跃度 高(Zilliz支持) 高(Facebook支持) 中(独立开发) 中(独立开发)
    适用场景 大规模RAG、推荐系统、图像检索 研究、原型开发 智能问答、知识图谱、语义搜索 中小规模RAG、语义搜索

查询和检索模块

查询变换

  1. 为什么要进行查询变换

    因为用户在提问时的表达方式可能较为局限,为了提高系统对用户查询意图的理解,需要通过查询变换的方式对查询文本进行调整、重写或扩展。

  2. 查询变换的方式

    1)同义改写:将原始查询改写成相同语义下不同的表达方式(改写工作可以调用 LLM 完成),针对每个改写后的查询分别检索出相关的文档,然后将这些结果合并和去重,最终得到一个更全面的答案集合。

    例如:原始查询为"如何进行LLM微调",可以通过查询变换为”LLM微调的方法有哪些“、”LLM微调包括哪些步骤“

    2)查询分解:将复杂问题分解成多个简单的子查询

    • 单步分解:将一个复杂查询转化为多个简单的子查询,逐一解答后再综合所有答案生成最终回复结果。

      例如,提问:”如何进行LLM微调?“,可以单步分解成:

      • 子查询一:“什么是LLM微调?”

      • 子查询二:“LLM微调中常用的技术有哪些?”

      • 子查询三:“如何选择微调的数据集?”

    • 多步分解:基于初始的复杂查询,综合前一步的回复结果生成下一步的查询问题,直到问不出更多问题为止。最后结合每一步的回复生成最终的结果。

    3)HyDE(假设文档嵌入):根据原始查询生成一个假设答案文档,将假设文档向量化之后在向量库中进行检索,可以找到更多上下文相关的答案。

排序和后处理

  1. 为什么要进行排序和后处理

    经过前面的检索过程可能会得到很多相关文档,但是初步检索到的结果可能并不是最相关的(余弦相似度检索一般适用于做一个快速检索,可能没有办法完全捕获到用户问题和文档向量之间的上下文语义联系),所以需要进行 reranking 对这些候选文档进行筛选和排序,挑选出最相关、最有用的文档作为生成过程的输入。

  2. 常用的筛选和排序策略

    • 基于相似度分数进行过滤和排序

    • 基于关键词进行过滤,比如限定包含或者不包含某些关键词

    • 让 LLM 基于返回的相关文档及其相关性得分来重新排序

    • 基于时间进行过滤和排序,比如只筛选最新的相关文档

    • 基于时间对相似度进行加权,然后进行排序和筛选

回复生成模块

回复生成策略

  1. 结合每个检索出的相关文本块,多次调用LLM不断修正生成的回复

    有多少个独立的相关文本块,就会产生多少次的 LLM 调用。

    1
    2
    3
    4
    5
    6
    举例: 假设用户的查询是:“如何进行LLM微调?”

    - 第一个相关文本块提到:“LLM微调是通过选择合适的训练数据集来适应特定任务。”
    - 第二个相关文本块提到:“使用迁移学习技术可以提高LLM的任务适应性。”

    系统首先基于第一个文本块生成一个初步的回答:“LLM微调是通过选择合适的训练数据集进行的。” 然后,基于第二个文本块,生成的回答会得到修正:“LLM微调是通过选择合适的训练数据集,并结合迁移学习技术来提高模型的任务适应性。”
    • 优点:确保了每个文本块的贡献都能反映到最终的回答中
    • 缺点:多次LLM调用,增加计算负担。
  2. 填充多个文本块到 Prompt 中,通过一次LLM调用生成回复

    如果一个 Prompt 中填充不下,可以分成多个 Prompt 来处理,每个 Prompt 中包含不同的文本块,最后汇总各个回复。

    1
    2
    3
    4
    5
    6
    7
    Context information is below.
    ---------------------
    LLM微调是通过选择合适的训练数据集。
    LLM微调结合迁移学习技术。
    微调的结果会根据不同的任务需求有所变化。
    ---------------------
    Using both the context information and also using your own knowledge, answer the question: 如何进行LLM微调?