检索增强生成 RAG(Retrieval Augmented Generation)应用算是一种比较容易落地的运用 LLM 的应用形态。RAG 最初由 Meta AI 提出,其基本思路是基于大语言模型(LLM)构建一个系统,在这个系统中通过访问 LLM 外部的知识源以解决知识密集型的任务并减少可能产生的幻觉。RAG 会接受输入并检索出一组相关的文档及来源,将得到的文档结合用户的 问题 发送给大语言模型得到最终的回复。
是否需要使用框架?
一般而言,实现 RAG 的应用的基本思路是:
- 针对特定的“领域“建立文档集。
- 将文档“合理地”拆分成为 Chunk;
- 在查询时,为用户的问题(query)生成 Embeddings,基于此在 Vector Database 中寻找关联度高的 Chunks;
- 结合 Chunks 和 query,使用 Prompt 向 LLM 进行提问,获取最终响应。
如上,构建一个 RAG 应用的流程是清晰且直接的,那是否有必要使用开源框架呢?目前有许多开源框架的都能简化开发 RAG 应用(如 LlamaIndex、LangChain),但是在使用过这些框架后(主要是 LlamaIndex.TS),总有种食之无味、弃之可惜的感觉。比如使用 LlamaIndex 构建 RAG 的流程为:
- 使用
DirectoryReader
加载文档; - 按需配置
IngestionPipeline
,在transformations
中确定如何拆分文档、使用何种 embedding 模型等; - 使用
VectorIndex
根据 query 获取 Nodes,必要时通过Postprocessors
对节点进行 rerank 等操作; - 使用(自定义)prompt 创建一个
ResponseSynthesizer
,结合 Nodes 获得 LLM 的响应。
在上面的流程中有几个要注意的点:
- 在加载文档的时候,
SimpleDirectoryReader
会直接将文件保存到一个 json 文件中,如果加载的文档的数量很多的话,这种方式完全不可行,因此还是需要结合技术栈实现BaseReader
接口,将文档信息保存到数据库中; - 在
IngestionPipeline
中,目前内置了SimpleNodeParser
、MarkdownNodeParser
和SentenceWindowNodeParser
,但是考虑到不同领域、中英文之间的差异,在拆分文档这块大概率还是需要自己实现。 VectorIndex
主要是封装了向量存储和检索,要注意的是,在使用PGVectorStore
的时候要注意连接(connection),必要时应当手动释放(await (await store.getClient()).end()
)。ResponseSynthesiszer
的作用是使检索到的 Nodes 结合特定的 Prompt 调用 LLM 回复用户的问题。默认的 Prompt 大概率不能满足需求(比如中文回复、添加聊天上下文),因此需要自定义 Prompt 用于构建ResponseSynthesiszer
。在ResponseBuilder
的选择上,因为大多的 LLM 都支持较长的上下文,因此建议使用SimpleResponseBuilder
,原因是 Refine 会进行多次 LLM 请求,导致响应时间过长,并且 Refine 和 QA 的 Prompt 并不相同,容易导致 LLM “失忆”,使得最终的回复效果欠佳。
综上,就个人经验而言,在构建一个 RAG 应用并不需要使用框架,针对特定部分的实现参考框架中的实现是可以的,但是在主流程上自行实现,能够减少许多理解框架的成本,也能在实现特定需求时保有更多的灵活性。
如何提升 RAG 应用的体验?
这里将 RAG 应用的体验分成两个部分:一是回答的速度,二是回答的准确性。
对于回答的速度,在不考虑网络因素的影响,大致可以看作文档检索的时间与 LLM 回复速度的和。如果使用 PgVector 进行文档检索,在使用默认索引时其时间复杂度为 O(n^2) (若使用 HNSW 索引则为 O(logn)),因此待查询的文档的数量会极大的影响检索的时间,一种优化的方法是将文档归类,然后仅检索问题涉及到的类别的文档。
LLM 的回复速度,如果没有任何前置请求,以 Stream 的方式回复,就用户体验上而言还是可以接受的。但是在实践中,不添加前置请求通常难以获得准确的回复,具体见后文。
回答的准确性主要依赖于用户提供的 问题 和根据 问题 检索到的相关文档,这里可以分成两块来看:
- 如何提升检索到的文档的相关性;
- 如何提升 LLM 对用户问题的理解。
对于提升文档检索相关性可以从两个方面入手:
- 使用更好的方式拆分文档;
- 采用更好的文档搜索方式。
对于文档拆分,目前大多是按段落及长度进行拆分,然后为拆分得到的段落添加部分前后文(简单的 overlapping 或添加前后文的总结),在使用向量数据库的前提下,文档的拆分很大程度上受限于使用的 Embedding 模型的上下文限制(这里也需要进行权衡,如果使用上下文长度长的模型,搜索的精度未必会比短上下文更好。另外需要注意向量的长度也会影响检索的速度)。总的来说,文档的拆分应当根据文档类别使用不同的方法进行拆分,比如法律法规这种格式化的文档,可以按章节条目进行拆分;互联网文章之类的最好能够使用 LLM 按表达的主题进行拆分。
除了考虑文档拆分方式外,如何搜索文档对提升回复准确性也有很大的影响。基于文本相似性的检索方式,要求被检索的文档和用户提出的 问题 间能够有足够的重合。当文档被拆分后,每个片段可能只能和 问题 中部分片段重合,在按照相似度筛选后,一些本相关的片段被丢弃,这会很大程度地降低回复的准确性。一种可能的方法是通过分析文章内容,针对文章建立知识图谱,通过实体间的关系进行检索,以确保获取到和文档相关的片段,比如 GraphRAG 的实现方式。
前面说到在正式回复前的前置请求很重要,具体来说,用户的 问题 是不受控的,通常会缺少重要的上下文信息,因此在实践中,通过一个前置的 LLM 请求,结合用户提问时的上下文信息(如在应用体系内的用户标签、当前的时间空间信息)去细化 问题 ,然后使用细化后的 问题 去检索和回复。通过这种方式能够有效地提升回复的准确性。这种前置请求既可以是 HyDE(Hypothetical Document Embeddings,即通过 LLM 先进行简单的回答,在使用这段回答去搜索文档),也可以将文档集合的选择融入其中(即通过 LLM 识别问题关联的文档集合,并生成针对该集合的搜索请求)。要注意的是:构造得到的前置请求的 Prompt 不要太长,前置请求的回复也应当尽量的简单,以避免在前置请求上耗费太多的时间。