一、前言
本人最近一直从事开发 RAG(Retrieval Augmented Generation) 相关应用的一些开发上。其中一款项目的主要能力是用户上传文档后再提问,最后由 LLM 分析后解答一切关于此文档相关问题。这样一款知识库应用。相信大家都有用过此类似的使用体验。
原理与现状
先简单介绍一下原理, 我们对于此类知识库应用,一般处理分两块。第一块是文档处理,另一块是 文档 Retriever (召回)。文档处理分为以下几步:
- 文档解析 - 对上传的文档进行解析成文本内容
- 文档切片 - 切成若干 chunk
- 向量存储 - 利用 embedding 模型将文本转换成向量,存放在向量数据库中。
文档召回分为以下几步:
- 向量搜索 - 利用余弦相似度算法活动与提问内容相关文档 chunk
- LLM请求 - 将获取到的内容,放到LLM 对话上下文中
- LLM返回 - LLM 根据你的上下文来回答问题
问题
实际上,我们如果不考虑模型因素 ,仅仅只靠上述方案做的话,会碰到很多问题:
- 如何保证文档切片不会造成相关内容的丢失? 比如 我有一段文本,刚好是完整的,如果从中间切开,那么则会造成信息丢失,给 LLM 的内容则不完整。
- 文档切片的大小如何控制? 太小则 容易造成信息丢失,太大则不利于向量检索命中。
- 文档召回过程中如何保证召回内容跟问题是相关的? embedding 模型 可能从未见过你文档的内容,也许你的文档的相似词也没有经过训练。所以不能保证召回的内容就非常准确,不准确则导致LLM回答容易产生幻觉(简而言之就是胡说八道)。
经验
目前 LangChain 在关于上述问题上,都有一套成熟的解决方案,在此我将介绍一下,如何用 LangChain 解决上述我所提到的问题。从而让模型回答的更好一些。
二、方案详解
文档处理
首先,我们采用 LangChain 的 MultiVector Retriever ,它的主要能力则是在做向量存储的过程进一步增强文档的检索能力。之前 LangChain 有 Parent Document Retriever 采用的方案是用小分块保证尽可能找到更多的相关内容,用大分块保证内容完整性, 这里的大块文档是指 Parent Document 。
为什么需要将 chunk 拆分大块和小块。这样的好处在于, 我们检索的文档可以保持一个细粒度,通过小块容易命中关键内容,但给LLM 的文档尽量保持一个完整通顺的内容,避免模型幻觉,所以在 Retriever 阶段返回的是大块的内容。
MultiVector Retriever 在 Parent Document Retriever 基础之上做了能力扩充。有了 Parent Document Retriever 那么为什么还有 MultiVector Retrieve ? 这是在因为文档处理的时候, 我们希望进一步的增强检索能力。 比如下图的 summary 和 hypothetical,这是让 LLM 在回答之前,提前对文档做一个分析。请看下图,这个时序图解释整个 MultiVector 的执行过程。
对 Parent Document 内容让 LLM 提前进行总结和提出一些假设性问题。 然后再将这三块内容(小块 chunk 的文档,总结性内容 和 假设问题 ) 存入向量数据库,并用 Parent Document ID 做关联。 进一步增强向量库的检索能力。
注意:( Parent Document ,长度控制有一定的讲究,要根据你的模型可以允许的长度来,但是不能打满,因为系统提示词占了一部分,保持Parent Document维持在 2 到3 个给 LLM)
文档召回
在召回阶段,我看到这样一个开源项目很有启发 sec-insights 它会将用户问题,采用多个不同的视角去提问,然后 LLM 会得出最终结果。当然如果这样回答 LLM 要调用多次,效率不高。我尝试改变了一下。
这里可以采用 LangChain 的 MultiQueryRetriever 主要原理则是 利用 LLM 尝试生成多个不同视角的问题,然后分别用这些问题做召回,然后再汇总。
比如下面实验的这个问题 "xx有哪些最新的功能?"
就比如生成了这样一系列的问题:
- xx有哪些最新的功能?
- 最新的xx功能有哪些?
- xx有什么最新的功能可以使用?
- 最新的功能是否已经在xx中推出?
- xx的新功能有哪些值得关注?
这样做的目的,我觉得是大多数人在问问题的过程中,如果不懂 prompt 工程,往往不专业,要么问题过于简单化,要么有歧义,意图不明显。那么向量搜索也是不准确的,导致LLM回答的效果不好。所以需要 LLM 进行问题的修正和多方位解读。
最后我们看一下,在召回阶段如何利用上面的 summary 和 hypothetical ,进一步提高召回准确率?
我是这样处理的, 根据 多个 question , 召回三份小的 chunk 数据。 见代码如下:
ini
unique_docs_hypo = qdrant.search(vectordb_hypo,query_list,0.8,1)
unique_docs_sum = qdrant.search(vectordb_sum,query_list,0.7,2)
unique_docs_vec = qdrant.search(vectordb,[question] + query_list,0.6,20)
# 合并召回结果
unique_docs = unique_docs_hypo + unique_docs_sum + unique_docs_vec
我们将 hypothetical 的召回分数定高一点, 命中 hypothetical ,一般是较为明确的内容,设为0.8分 (非常高了,实验结果是,假设的问题几乎一样才能命中)
分数以此类推。然后得到一个总的 chunk docs 列表。 然后反查出 Parent Document List 丢给大模型去回答。
文档重排
最后一步则是文档重排 来自此论文 : arxiv.org/abs/2307.03... LLM 对位置是相对比较敏感的,得分好的放在首或尾,LLM会重点关注。那么重排后,通过实验,模型回答的效果的确要好一些。在 LangChain 中也实现了此重排的方案 LongContextReorder (详细见文档)
Embeddings
目前在 huggingface 公开的 Embedding 不少,目前中文能力较好的不多。本文选用了目前中文 SOTA bge-large-zh LangChain 已经整合此模型,可以非常简单的使用。
LangChain 提供了 CacheBackedEmbeddings , 可以提高 embedings 的二次加载和解析的效率,首次正常速度,后续有一个 3倍效率的提升。
流程图
总结
其实我也准备了一些示例,来演示和对比一下改进的效果。目前只对内演讲,对外不方便透露。但在召回效果和回答上,有了很大的提升。但是我觉得还存在进一步优化的空间,比如用户的问题完全不相关怎么处理? 文档向量搜索完全不准(大概率未训练,内容属于专业知识),如何弥补。等等。其他后续能够继续为大家带来深入的分享和交流。重点:此文章点赞过千则开源代码。