精彩手绘全解:RAG技术,从入门到精通
本文整理自IVAN ILIN发布于Towards AI的博客[1]。感谢作者的精彩讲解。
深度学习自然语言处理 分享
整理:Winnie
引言
检索增强生成(Retrieval Augmented Generation,简称RAG)为大型语言模型(LLMs)提供了从某些数据源检索到的信息,以此作为生成答案的基础。简而言之,RAG是搜索+LLM提示的结合,即在有搜索算法找到的信息作为上下文的情况下,让模型回答提出的查询。查询和检索到的上下文都被注入到发送给LLM的提示中。
目前,RAG是基于LLM系统中最受欢迎的架构。许多产品几乎完全基于RAG构建,包括将网络搜索引擎与LLMs相结合的问答服务,以及数百种与数据聊天的应用程序。
即使是向量搜索领域也因这种热潮而兴起,尽管基于嵌入的搜索引擎自2019年就已使用faiss开发。像chroma、weavaite.io和pinecone这样的向量数据库初创公司建立在现有的开源搜索索引之上--主要是faiss和nmslib,并且最近增加了对输入文本的额外存储和一些其他工具。
有两个最突出的开源库用于基于LLM的管道和应用程序——LangChain和LlamaIndex,它们分别于2022年10月和11月成立,并在2023年获得了大量的应用。
本文的目的是系统化地介绍高级RAG技术的关键点,并参考它们在LlamaIndex中的实现——以便帮助其他开发者深入了解这项技术。
如果您已经熟悉RAG概念,请直接跳到高级RAG部分。
基础的RAG技术
本文中,我们用一组文本文档的语料库来代表RAG的起点——我们跳过了在此之前的步骤,留给那些开源数据加载器去处理,这些加载器可以连接到任何可想象的来源,从YouTube到Notion。
简单RAG案例大致如下:
将文本分割成块,然后使用基于Transformer decoder的模型将这些块嵌入到向量中,将所有这些向量放入一个索引中,最后为LLM创建一个提示,告诉模型在我们在搜索步骤中找到的上下文中回答用户的查询。 在运行时,我们使用相同的编码器模型将用户的查询向量化,然后对索引执行这个查询向量的搜索,找到前k个结果,从我们的数据库中检索相应的文本块,并将它们作为上下文输入到LLM的提示中。
提示可能看起来是这样的:
def question_answering(context, query):
prompt = f"""
Give the answer to the user query delimited by triple backticks ```{query}```\
using the information given in context delimited by triple backticks ```{context}```.\
If there is no relevant information in the provided context, try to answer yourself,
but tell user that you did not have any relevant context to base your answer on.
Be concise and output the answer of size less than 80 tokens.
"""
response = get_completion(instruction, prompt, model="gpt-3.5-turbo")
answer = response.choices[0].message["content"]
return answer
提示工程是提升RAG管道性能最经济的尝试之一。确保您已经查看了OpenAI提供的提示工程指南[2]。
尽管OpenAI作为LLM领域领导公司,但还有一些替代品,如Anthropic的Claude,最近流行的小型但非常强大的模型,如Mistral的Mixtral,Microsoft的Phi-2,以及许多开源选项,如Llama2、OpenLLaMA、Falcon等,所以您可以为您的RAG管道选择一个“大脑”。
高级RAG技术
现在我们将深入了解高级RAG技术的概述。下面是一个展示核心步骤的示意图。为了保持图表的可读性,省略了一些逻辑循环和复杂的多步骤代理行为。
图中的绿色元素是接下来讨论的核心RAG技术,蓝色元素代表文本。并非所有高级RAG理念都能轻易在单一图表上可视化,例如,省略了各种扩大上下文的方法——我们将在后面深入探讨这些。
切分和向量化
首先,我们想创建一个向量索引,代表我们文档的内容,然后在运行时搜索这些向量与查询向量之间最小的余弦距离,对应于最接近的语义含义。
切分:Transformer模型有固定的输入序列长度,即使输入上下文窗口很大,一个句子或几个句子的向量也比几页文本的平均向量更好地代表它们的语义含义(也取决于模型,但通常如此),所以要切分你的数据——将初始文档切分为某个大小的块,不会丢失它们的含义(将文本切分为句子或段落,而不是将单个句子切成两部分)。有各种文本分割器实现能够完成这项任务。
块的大小是一个需要考虑的参数——它取决于你使用的嵌入模型及其在令牌上的容量,标准的Transformer编码器模型如基于BERT的句子转换器最多接受512个令牌,OpenAI ada-002能够处理更长的序列,如8191个令牌,但这里的折中是为LLM提供足够的上下文进行推理与执行搜索的足够具体的文本嵌入。最近的一项研究[3]说明了块大小选择的考虑因素。在LlamaIndex中,这是通过NodeParser类来覆盖的,它提供了一些高级选项,如定义自己的文本分割器、元数据、节点/块关系等。
向量化:下一步是选择一个模型来嵌入切割后的块——有很多选择,例如像bge-large或E5嵌入系列这样的搜索优化模型——只需查看MTEB排行榜上[4]的最新更新。
要了解切分和向量化步骤的端到端实现,请查看LlamaIndex中的一个完整的示例。
搜索索引
向量存储索引: RAG管道的关键部分是搜索索引,它存储了我们在上一步中获得的向量化内容。最简单的实现使用平面索引——在查询向量和所有块向量之间进行暴力距离计算。
一个为10000+元素规模上的高效检索优化的索引是一个向量索引,如faiss、nmslib或annoy,使用某种近似最近邻实现,如聚类、树或HNSW算法。
还有一些托管解决方案,如OpenSearch或ElasticSearch,以及向量数据库,它们在后台处理第1步中描述的数据摄取管道,如Pinecone、Weaviate或Chroma。根据选择的索引、数据和搜索需求,可以将元数据与向量一起存储,然后使用元数据过滤器来搜索某些日期或来源内的信息。
LlamaIndex支持许多向量存储索引,但还支持其他更简单的索引实现,如列表索引、树索引和关键词表索引——我们将在融合检索部分讨论后者。
分层索引: 如果您需要从许多文档中检索信息,您需要能够有效地在其中搜索,找到相关信息,并将其综合为带有来源引用的单一答案。在大型数据库中做到这一点的有效方法是创建两个索引——一个由摘要组成,另一个由文档块组成,并分两步进行搜索,首先通过摘要筛选出相关文档,然后仅在这个相关组内搜索。
假设性问题和HyDE: 另一种方法是让LLM为每个块生成一个问题,并将这些问题嵌入向量中,在运行时针对这个问题向量索引进行查询搜索(在我们的索引中用问题向量替换块向量),然后在检索后路由到原始文本块,并将它们作为上下文发送给LLM以获得答案。这种方法通过查询与假设性问题之间更高的语义相似性,提高了搜索质量。
还有一种逆向逻辑方法称为HyDE——让LLM给定查询生成一个假设性回应,然后使用其向量和查询向量来提高搜索质量。
上下文丰富化: 上下文丰富化是检索更小的块以提高搜索质量,但添加周围上下文让LLM进行推理。通常有两种做法——通过在检索到的较小块周围的句子扩展上下文,或者将文档递归地分割成包含较小子块的多个较大的父块。
句子窗口检索: 在这个方案中,文档中的每个句子都分别嵌入,这提供了极高的查询与上下文余弦距离搜索的准确性。为了在找到最相关的单个句子后更好地推理所发现的上下文,我们通过在检索到的句子前后扩展k个句子的上下文窗口,然后将这个扩展的上下文发送给LLM。
自动合并检索器(又称父文档检索器):这里的想法与句子窗口检索非常相似——搜索更精细的信息片段,然后在将这些上下文提供给LLM进行推理之前扩展上下文窗口。文档被分割成较小的子块,这些子块引用较大的父块。
在这种方法中,首先在更细粒度的子块上进行搜索,找到与查询最相关的块。然后,系统会自动将这些子块与它们所属的更大的父块结合起来。这样做的目的是在回答查询时为LLM提供更丰富的上下文。例如,如果一个子块是一段或一小节,父块可能是整个章节或文档的一大部分。这种方法既保留了检索精度(因为是在更小的块上搜索),同时也通过提供更广泛的上下文来增强LLM的推理能力。
在检索过程中首先获取较小的块,然后如果在检索到的前k个块中有超过n个块与同一个父节点(较大的块)相关联,我们就用这个父节点替换提供给LLM的上下文——这就像自动将几个检索到的块合并成一个较大的父块,因此得名。需要注意的是——搜索仅在子节点索引中进行。想要更深入地了解,请查看LlamaIndex关于递归检索器+节点引用的教程[5]。
融合检索或混合搜索:这是一个相对较老的想法,即从两个世界中各取所长——基于关键字的传统搜索(稀疏检索算法,如tf-idf或搜索行业标准BM25)和现代语义或向量搜索,并将它们结合在一个检索结果中。这里唯一的技巧是正确组合具有不同相似性得分的检索结果——这个问题通常通过使用倒数排名融合算法来解决,重新排列检索结果以获得最终输出。
在LangChain[6]中,这是通过Ensemble Retriever类实现的,它结合了你定义的一系列检索器,例如faiss向量索引和基于BM25的检索器,并使用RRF进行重排。在LlamaIndex[7]中这种做法也非常类似。
混合或融合搜索通常会提供更好的检索结果,因为它结合了两种互补的搜索算法,同时考虑了查询和存储文档之间的语义相似性和关键词匹配。
重排和过滤
使用上述任何算法得到检索结果后,现在是时候通过过滤、重排或一些转换来精炼这些结果了。在LlamaIndex中,有多种可用的后处理器,可以根据相似性分数、关键词、元数据过滤结果,或者使用其他模型进行重排,比如LLM、句子转换器交叉编码器、Cohere重排端点,或者基于日期的最新性等元数据——基本上,你能想到的都可以。
重排和过滤是在将检索到的上下文提供给LLM以获取最终答案之前的最后一步。现在是时候进入更复杂的RAG技术,如查询转换和路由,这两者都涉及到LLM,因此代表了主动性行为——在我们的RAG流程中涉及到一些复杂的逻辑,包括LLM的推理。
查询转换
查询转换是一系列技术,利用LLM作为推理引擎来修改用户输入,以提高检索质量。
有几种不同的方式可以做到这一点。如果查询很复杂,LLM可以将其分解成几个子查询。例如,如果你问:
“在Github上,Langchain和LlamaIndex哪个框架的星星更多?” 由于我们不太可能在语料库中找到直接的比较,所以将这个问题分解成两个预设简单和具体信息检索的子查询是有意义的: “Langchain在Github上有多少星星?” “LlamaIndex在Github上有多少星星?” 这两个查询将并行执行,然后将检索到的上下文合并成一个提示,供LLM合成最初查询的最终答案。Langchain和LlamaIndex都实现了这一功能——在Langchain中作为多查询检索器,在LlamaIndex中作为子问题查询引擎。
回溯提示使用LLM生成更一般的查询,我们为此检索获得更一般或高层次的上下文,有助于支撑我们对原始查询的回答。也会对原始查询进行检索,两种上下文都在最终生成答案的步骤中输入给LLM。这是LangChain的实现方法。
查询重写使用LLM重构初始查询以改善检索。LangChain和LlamaIndex都有实现,虽然有些不同,但我认为在这里LlamaIndex的解决方案更为强大。
此外, 还有一个概念是参考引用。这一部分不作为单独的一章来介绍,因为它更像是一种工具而不是检索改进技术,尽管它非常重要。如果我们为了回答一个问题而使用了多个来源,可能是因为初始查询的复杂性(我们需要执行多个子查询,然后将检索到的上下文合并成一个答案),或者是因为我们在不同的文档中找到了与单个查询相关的上下文,那么就会出现一个问题:我们能否准确地回溯引用我们的来源。
有几种方法可以做到这一点:
将引用任务插入我们的提示中,并要求LLM提及使用的来源的ID。 将生成的响应部分与我们索引中的原始文本块匹配——llamaindex为这种情况提供了一个基于模糊匹配的高效解决方案。如果你还没有听说过模糊匹配,这是一种非常强大的字符串匹配技术。
聊天引擎
在构建一个能够针对单个查询多次运行的优秀RAG系统中,下一个重要的环节是聊天逻辑,这与前LLM时代的经典聊天机器人一样,需要考虑对话上下文。这对于支持后续问题、指代消解或与先前对话上下文相关的任意用户命令是必要的。这可以通过查询压缩技术来解决,同时考虑聊天上下文和用户查询。
如同往常,有几种处理上述上下文压缩的方法 — 一种流行且相对简单的方法是ContextChatEngine,它首先检索与用户查询相关的上下文,然后将其连同聊天历史记录从内存缓冲区发送给LLM,以便LLM在生成下一个回答时能够了解之前的上下文。
更复杂的一个例子是CondensePlusContextMode — 在这种模式中,每次交互时都会将聊天历史和最后一条消息压缩成一个新的查询,然后这个查询会进入索引,检索到的上下文连同原始用户消息一起传递给LLM,以生成答案。
值得注意的是,LlamaIndex还支持基于OpenAI代理的聊天引擎,提供更灵活的聊天模式,Langchain也支持OpenAI功能性API。还有其他类型的聊天引擎,如ReAct Agent,但我们在后面再讨论代理本身。
查询路由
查询路由是一个以LLM为驱动的决策步骤,决定针对用户查询接下来要做什么——通常的选项包括概括总结、针对某些数据索引执行搜索,或尝试多种不同的路径,然后将它们的输出合成一个答案。
查询路由器还用于选择索引,或更广泛地说,数据存储位置,以发送用户查询——无论你拥有多个数据来源,例如经典的向量存储、图形数据库或关系型数据库,还是拥有一个索引层次结构——对于多文档存储,一个相当典型的情况可能是一个概要索引和另一个文档块向量的索引。
定义查询路由器包括设置它可以做出的选择。路由选项的选择是通过LLM调用进行的,返回预定义格式的结果,用于将查询路由到给定的索引,或者,如果我们谈论主动性行为,路由到子链或甚至其他代理,如下面的多文档代理方案所示。
LlamaIndex和LangChain都支持查询路由器。
RAG中的代理
Langchain和LlamaIndex都支持的代理(Agents),自从第一个LLM API发布以来就已经存在——这个想法是为一个能够进行推理的LLM提供一套工具和一个要完成的任务。这些工具可能包括一些确定性函数,如任何代码函数、外部API甚至其他代理——LLM链式调用的这个想法是LangChain名字的由来。
代理本身是一个巨大的领域,要在RAG概览中深入探讨是不可能的,所以我将继续讨论基于代理的多文档检索案例,并在OpenAI助手这个相对较新的领域短暂停留,因为它是最近OpenAI开发者大会上作为GPTs介绍的,并在下面描述的RAG系统的底层工作。
OpenAI助手基本上实现了围绕LLM所需的许多工具,我们之前在开源中拥有这些工具——聊天历史记录、知识存储、文档上传界面,以及或许最重要的,函数调用API。后者提供了将自然语言转换为对外部工具或数据库查询的API调用的能力。
在LlamaIndex中,OpenAIAgent类将这种高级逻辑与ChatEngine和QueryEngine类结合起来,提供基于知识和上下文的聊天,以及在一次对话中调用多个OpenAI函数的能力,这确实带来了智能的代理行为。
让我们来看一下多文档代理方案——一个相当复杂的设置,涉及对每个文档初始化一个代理(OpenAIAgent),能够进行文档概要和经典的问答机制,并有一个顶级代理,负责将查询路由到文档代理,并进行最终答案的合成。
每个文档代理都有两个工具——一个向量存储索引和一个概要索引,并根据路由查询决定使用哪一个。而对于顶级代理来说,所有文档代理都分别是工具。
这个方案展示了一个高级的RAG架构,其中每个参与的代理都做出了许多路由决策。这种方法的好处是能够比较不同的解决方案或实体,这些解决方案或实体描述在不同的文档及其概要中,同时包括经典的单文档概要和问答机制——这基本上涵盖了最常见的与文档集合聊天的用例。这种复杂方案的缺点可以从图中猜测——由于涉及代理中LLM的多次来回迭代,它有些慢。顺便说一下,LLM调用总是RAG流程中最长的操作——搜索本身就是为速度优化的设计。所以对于大型多文档存储,我建议考虑对这个方案进行一些简化,使其可扩展。
响应合成器
这是任何RAG流程的最后一步——基于我们仔细检索的所有上下文和初始用户查询生成答案。最简单的方法可能是将所有获取到的上下文(超过某个相关性阈值的)连同查询一起一次性输入给LLM。但是,像往常一样,还有其他更复杂的选项,涉及多次LLM调用以优化检索到的上下文并生成更好的答案。
响应合成的主要方法包括:
通过逐块将检索到的上下文发送给LLM来迭代地完善答案。 概括检索到的上下文以适应提示。 基于不同的上下文块生成多个答案,然后将它们连接或概括起来。有关更多细节,请查阅响应合成器模块文档[8]。
编码器和LLM微调
这种方法涉及对RAG流程中的两个深度学习模型之一进行微调——要么是负责嵌入质量和上下文检索质量的Transformer编码器,要么是负责最佳利用提供的上下文来回答用户查询的LLM,幸运的是,后者是一个很好的少量样本学习器。
如今一个很大的优势是能够使用像GPT-4这样的高端LLM来生成高质量的合成数据集。但使用由专业研究团队在精心收集、清洗和验证的大型数据集上训练的开源模型,并使用小型合成数据集进行快速调整,可能会降低模型的整体能力。
编码器微调: 我对编码器微调方法也有些怀疑,因为最新的为搜索优化的Transformer编码器相当高效。所以我在LlamaIndex笔记本设置中测试了对bge-large-en-v1.5(在撰写本文时为MTEB排行榜前4)进行微调的性能提升,结果显示检索质量提高了2%。虽然不是很惊人,但了解这个选项还是不错的,尤其是如果你有一个你正在为之构建RAG的狭窄领域数据集。
排名器微调: 另一个老方法是,如果你不完全信任你的基础编码器,就使用交叉编码器对检索结果进行重排。其工作方式如下——你将查询和前k个检索到的文本块传递给交叉编码器,以SEP令牌分隔,并对其进行微调,以输出1表示相关块,0表示不相关。这里有一个这种调整过程的例子[9],结果显示交叉编码器微调提高了4%的成对分数。
LLM微调: 最近OpenAI开始提供LLM微调API,LlamaIndex有关于在RAG设置中微调GPT-3.5-turbo的教程[10],以“提炼”一些GPT-4的知识。这里的想法是拿一个文档,用GPT-3.5-turbo生成一些问题,然后使用GPT-4根据文档内容生成这些问题的答案(构建一个由GPT4驱动的RAG流程),然后对GPT-3.5-turbo进行微调,使其在问题-答案对的数据集上进行训练。用于RAG流程评估的ragas框架显示,忠实度指标提高了5%,意味着微调后的GPT 3.5-turbo模型比原始模型更好地利用了提供的上下文来生成其答案。
一种更复杂的方法在最近的RA-DIT论文[11]中展示:由Meta AI研究提出的检索增强双指导调整技术,建议对LLM和检索器(原论文中的双编码器)进行调整,针对查询、上下文和答案的三元组。有关实现细节,请参考这个指南[12]。这种技术用于通过微调API对OpenAI LLM进行微调,以及对Llama2开源模型进行微调(在原论文中),结果显示在知识密集型任务指标上提高了约5%(与Llama2 65B with RAG相比),以及在常识推理任务上提高了几个百分点。
评估
RAG系统性能评估有几个框架,它们共享一个理念,即拥有几个独立的指标,如整体答案相关性、答案的根据性、忠实度和检索到的上下文相关性。
前一节提到的Ragas使用忠实度和答案相关性作为生成答案质量的指标,以及经典的上下文精确度和召回率用于RAG方案的检索部分。
在Andrew NG最近发布的精彩短课程《构建和评估高级RAG》中,LlamaIndex和评估框架Truelens建议使用RAG三元组——检索到的上下文与查询的相关性、根据性(LLM答案受提供的上下文支持的程度)以及答案与查询的相关性。
最关键且最可控的指标是检索到的上下文相关性——基本上上面描述的高级RAG流程的第1-7部分以及编码器和排名器微调部分旨在改善这一指标,而第8部分和LLM微调则专注于答案相关性和根据性。
一个相当简单的检索器评估流程的例子可以在这里[13]找到,并且已应用于编码器微调部分。一种更高级的方法不仅考虑命中率,还考虑了平均倒数排名(一个常见的搜索引擎指标)以及生成答案的指标,如忠实度和相关性,这在OpenAI cookbook[14]中有所展示。
LangChain有一个相当先进的评估框架LangSmith[15],可以实现自定义评估器,它还监控RAG流程中的运行轨迹,以使你的系统更透明。
如果你在使用LlamaIndex构建,那么有一个rag_evaluator llama包[16],提供了一个快速工具,用公共数据集评估你的流程。
结论
我试图概述RAG的核心算法方法,并且用一些示例来说明它们,希望这能激发一些在你的RAG流程中尝试的新想法,或者为今年发明的众多技术带来一些系统化——对我来说,2023年到目前为止是ML领域最激动人心的一年。
还有许多其他需要考虑的事情,如基于网络搜索的RAG(LlamaIndex的RAGs、webLangChain等),更深入地探讨主动架构(以及最近OpenAI在这个游戏中的份额)以及一些关于LLM长期记忆的想法。
RAG系统的主要生产挑战除了答案相关性和忠实度之外,还有速度,尤其是如果你倾向于更灵活的基于代理的方案。ChatGPT和大多数其他助手使用的这种流媒体功能不是随机的赛博朋克风格,而只是一种缩短感知答案生成时间的方式。这就是为什么我看到小型LLM和最近的Mixtral和Phi-2发布在这个方向上有一个非常光明的未来。
参考资料
Advanced RAG Techniques: an Illustrated Overview: https://pub.towardsai.net/advanced-rag-techniques-an-illustrated-overview-04d193d8fec6
[2]提示工程指南: https://platform.openai.com/docs/guides/prompt-engineering/strategy-write-clear-instructions
[3]chunking-strategies: https://www.pinecone.io/learn/chunking-strategies/
[4]MTEB排行榜: https://huggingface.co/spaces/mteb/leaderboard
[5]LlamaIndex关于递归检索器+节点引用的教程: https://docs.llamaindex.ai/en/stable/examples/retrievers/recursive_retriever_nodes.html
[6]LangChain: https://python.langchain.com/docs/modules/data_connection/retrievers/ensemble
[7]LlamaIndex: https://docs.llamaindex.ai/en/stable/examples/retrievers/reciprocal_rerank_fusion.html
[8]响应合成器模块文档: https://docs.llamaindex.ai/en/stable/module_guides/querying/response_synthesizers/root.html
[9]cross_encoder_finetuning: https://docs.llamaindex.ai/en/latest/examples/finetuning/cross_encoder_finetuning/cross_encoder_finetuning.html#
[10]openai_fine_tuning: https://docs.llamaindex.ai/en/stable/examples/finetuning/openai_fine_tuning.html
[11]RA-DIT: https://arxiv.org/pdf/2310.01352.pdf
[12]fine-tuning-with-retrieval-augmentation: https://docs.llamaindex.ai/en/stable/examples/finetuning/knowledge/finetune_retrieval_aug.html#fine-tuning-with-retrieval-augmentation
[13]evaluate.ipynb: https://github.com/run-llama/finetune-embedding/blob/main/evaluate.ipynb
[14]Evaluate_RAG_with_LlamaIndex: https://github.com/openai/openai-cookbook/blob/main/examples/evaluation/Evaluate_RAG_with_LlamaIndex.ipynb
[15]LangSmith: https://docs.smith.langchain.com/
[16]rag_evaluator llama包: https://github.com/run-llama/llama-hub/tree/dac193254456df699b4c73dd98cdbab3d1dc89b0/llama_hub/llama_packs/rag_evaluator
微信扫码关注该文公众号作者