cover

一些开发 RAG 应用的想法

一些开发 RAG 应用的想法。

2024-07-13

检索增强生成 RAG(Retrieval Augmented Generation)应用算是一种比较容易落地的运用 LLM 的应用形态。RAG 最初由 Meta AI 提出,其基本思路是基于大语言模型(LLM)构建一个系统,在这个系统中通过访问 LLM 外部的知识源以解决知识密集型的任务并减少可能产生的幻觉。RAG 会接受输入并检索出一组相关的文档及来源,将得到的文档结合用户的 问题 发送给大语言模型得到最终的回复。

heading

是否需要使用框架?

一般而言,实现 RAG 的应用的基本思路是:

  1. 针对特定的“领域“建立文档集。
  2. 将文档“合理地”拆分成为 Chunk;
  3. 在查询时,为用户的问题(query)生成 Embeddings,基于此在 Vector Database 中寻找关联度高的 Chunks;
  4. 结合 Chunks 和 query,使用 Prompt 向 LLM 进行提问,获取最终响应。

如上,构建一个 RAG 应用的流程是清晰且直接的,那是否有必要使用开源框架呢?目前有许多开源框架的都能简化开发 RAG 应用(如 LlamaIndexLangChain),但是在使用过这些框架后(主要是 LlamaIndex.TS),总有种食之无味、弃之可惜的感觉。比如使用 LlamaIndex 构建 RAG 的流程为:

  1. 使用
    DirectoryReader
    加载文档;
  2. 按需配置
    IngestionPipeline
    ,在
    transformations
    中确定如何拆分文档、使用何种 embedding 模型等;
  3. 使用
    VectorIndex
    根据 query 获取 Nodes,必要时通过
    Postprocessors
    对节点进行 rerank 等操作;
  4. 使用(自定义)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 应用并不需要使用框架,针对特定部分的实现参考框架中的实现是可以的,但是在主流程上自行实现,能够减少许多理解框架的成本,也能在实现特定需求时保有更多的灵活性。

heading

如何提升 RAG 应用的体验?

这里将 RAG 应用的体验分成两个部分:一是回答的速度,二是回答的准确性。

对于回答的速度,在不考虑网络因素的影响,大致可以看作文档检索的时间与 LLM 回复速度的和。如果使用 PgVector 进行文档检索,在使用默认索引时其时间复杂度为 O(n^2) (若使用 HNSW 索引则为 O(logn)),因此待查询的文档的数量会极大的影响检索的时间,一种优化的方法是将文档归类,然后仅检索问题涉及到的类别的文档。

LLM 的回复速度,如果没有任何前置请求,以 Stream 的方式回复,就用户体验上而言还是可以接受的。但是在实践中,不添加前置请求通常难以获得准确的回复,具体见后文。

回答的准确性主要依赖于用户提供的 问题 和根据 问题 检索到的相关文档,这里可以分成两块来看:

  1. 如何提升检索到的文档的相关性;
  2. 如何提升 LLM 对用户问题的理解。

对于提升文档检索相关性可以从两个方面入手:

  1. 使用更好的方式拆分文档;
  2. 采用更好的文档搜索方式。

对于文档拆分,目前大多是按段落及长度进行拆分,然后为拆分得到的段落添加部分前后文(简单的 overlapping 或添加前后文的总结),在使用向量数据库的前提下,文档的拆分很大程度上受限于使用的 Embedding 模型的上下文限制(这里也需要进行权衡,如果使用上下文长度长的模型,搜索的精度未必会比短上下文更好。另外需要注意向量的长度也会影响检索的速度)。总的来说,文档的拆分应当根据文档类别使用不同的方法进行拆分,比如法律法规这种格式化的文档,可以按章节条目进行拆分;互联网文章之类的最好能够使用 LLM 按表达的主题进行拆分。

除了考虑文档拆分方式外,如何搜索文档对提升回复准确性也有很大的影响。基于文本相似性的检索方式,要求被检索的文档和用户提出的 问题 间能够有足够的重合。当文档被拆分后,每个片段可能只能和 问题 中部分片段重合,在按照相似度筛选后,一些本相关的片段被丢弃,这会很大程度地降低回复的准确性。一种可能的方法是通过分析文章内容,针对文章建立知识图谱,通过实体间的关系进行检索,以确保获取到和文档相关的片段,比如 GraphRAG 的实现方式。

前面说到在正式回复前的前置请求很重要,具体来说,用户的 问题 是不受控的,通常会缺少重要的上下文信息,因此在实践中,通过一个前置的 LLM 请求,结合用户提问时的上下文信息(如在应用体系内的用户标签、当前的时间空间信息)去细化 问题 ,然后使用细化后的 问题 去检索和回复。通过这种方式能够有效地提升回复的准确性。这种前置请求既可以是 HyDE(Hypothetical Document Embeddings,即通过 LLM 先进行简单的回答,在使用这段回答去搜索文档),也可以将文档集合的选择融入其中(即通过 LLM 识别问题关联的文档集合,并生成针对该集合的搜索请求)。要注意的是:构造得到的前置请求的 Prompt 不要太长,前置请求的回复也应当尽量的简单,以避免在前置请求上耗费太多的时间。