大型语言模型(LLMs)的知识更新到某个日期为止,无法回答关于其知识库中不存在的信息查询。例如,LLMs无法回答关于去年该公司会议纪要的信息。同样,它们还可能提供看似合理但实际上可能错误的答案。
为了解决这个问题,检索增强生成(RAG)解决方案正变得越来越受欢迎。RAG的主要思想是将外部文档整合到大型语言模型(LLM)里,并引导其仅根据外部知识库回答问题。这通过将文档分割成小片段,计算每个片段的数值表示(嵌入),并将这些嵌入存储在一个专门向量数据库的索引中来实现。
RAG工作流:查询被转换成嵌入,通过检索模型在向量数据库中匹配,并结合检索到的数据,通过LLM生成响应(图片由作者提供)。
RAG上下文检索.将用户的查询与向量数据库中的小块数据进行匹配的过程通常效果良好,然而,它有一些需要注意的地方:
为了应对这些问题,Anthropic最近提出了一种方法来为每个片段添加上下文,这种方法显著提高了性能,超过了简单的RAG。首先,文档被拆分成多个片段,然后每个片段都会与整个文档一起发送给LLM,以获取简短的上下文,随后这些带有上下文信息的片段被保存到向量数据库中。此外,结合了上下文划分和best match,使用bm25检索器来搜索文档,并使用一个重排序模型来根据相关性为每个检索到的片段分配排序分数。
多模态RAG带上下文检索尽管性能有了显著提升,Anthropic 只展示了这些方法在文本上的适用性。许多文档中信息的丰富来源是图像(图表、图形)和复杂的表格。如果我们只是从文档中提取文本,就无法获得其中的其他信息形式,如图像和复杂表格。包含图像和复杂表格的文档需要高效的解析方法,这不仅要求准确地从文档中提取这些图像和表格,还需要理解其意义。
为文档中的每个片段分配上下文,使用Anthropic最新模型(claude-3–5-sonnet-20240620)在处理大型文档时可能会有较高的成本,因为每次发送片段时都需要发送整个文档。尽管Claude的prompt缓存技术可以通过在API调用之间缓存常用的上下文来显著降低这种成本,但其成本仍然远高于OpenAI的低成本模型,如gpt-4o-mini。
这篇文章讨论了对Anthropic方法的一些扩展,如下所述:
将所有内容,包括文本、表格和图片,提取并转换成结构良好的Markdown格式。
不使用文本分割器将文档分割成块,而是使用节点解析器(nodes)将文档解析成节点。这不仅涉及分割文本,还包括理解文档的结构、语义和元数据。
在阅读了 Anthropic 的这篇关于上下文检索的博客文章之后,我发现了一个 OpenAI 的部分实现,链接在 GitHub 这里链接。然而,它采用传统的分块处理和 LlamaParse,并没有采用最近引入的 Llamaparse 高级模式。我发现 Llamaparse 的付费模式在提取文档中的不同结构方面更为高效。
Anthropic的上下文检索实现也可以在GitHub上找到,其链接为此处,使用了LlamaIndex抽象。然而,它并没有实现多模态解析功能。撰写本文时,有一个更近的实现来自LlamaIndex,这使用多模态解析和上下文检索。该实现使用Anthropic的LLM(claude-3–5-sonnet-2024062)和Voyage的嵌入模型(voyage-3)。然而,它们没有像Anthropic的博客文章中提到的那样探索“最佳搜索25”和“重排序”。
本文讨论的上下文检索实现是一种低成本、多模态的RAG解决方案,通过BM25搜索和重排序提高了检索性能。本文还比较了这种基于上下文检索的多模态RAG(CMRAG)与基础的RAG以及_LlamaIndex_提供的上下文检索实现的性能。重用了这些链接中的部分功能并进行了必要的调整:1,2,3,4。
_此实现的代码可以在GitHub上找到。_
本文中用于实现某个目标(如某功能或某效果)的总体方法如下:用于实现CMRAG的如下方法。
这些解析后的节点在保存到向量数据库之前会被赋予上下文。这种上下文检索包括结合嵌入(语义搜索)以及TF-IDF向量(最佳匹配搜索),然后通过重排模型进行重排,最后由LLM生成最终回复。(作者提供的图片)
咱们一步一步来看一下如何实现CMRAG吧。
多模态解析技术为了运行本文中讨论的代码,需要安装以下库包。
!pip install llama-index ipython cohere rank-bm25 pydantic nest-asyncio python-dotenv openai llama-parse
所有运行整个代码所需的库都列在了GitHub 笔记本中。在这篇文章里,我使用了芬兰的移民统计数据(根据CC By 4.0许可,允许再利用),其中包含多个图表、图片和文本资料。
LlamaParse 提供多模态解析功能,使用供应商提供的多模态模型(如 gpt-4o)来提取文档信息。
parser = 独角兽解析器( 使用供应商多模态模型=True, 供应商多模态模型名称="openai-gpt-4o", 供应商多模态API密钥=sk-proj-xxxxxx # 这里应填写真实的API密钥 )
在这种模式下,每页文档都会被截图,然后将这些截图连同提取为Markdown的格式的指令一起发送给多模态模型。每页的Markdown的结果会整合到最终输出中。
最近的LlamaParse Premium模式提供了高级的多模态文档解析功能,能够提取文本、表格和图片并将其转化为结构良好的Markdown格式,同时显著减少了幻觉。你可以在Llama Cloud平台免费创建账户并获取API密钥来使用它。免费计划每天可以解析多达1,000页。
LlamaParse 的高级模式用法如下:
from llama_parse import LlamaParse import os # 读取指定目录下所有文件的函数 def read_docs(data_dir) -> List[str]: files = [] for f in os.listdir(data_dir): fname = os.path.join(data_dir, f) if os.path.isfile(fname): files.append(fname) return files # 初始化解析器 parser = LlamaParse( result_type="markdown", premium_mode=True, # 获取API密钥 api_key=os.getenv("LLAMA_CLOUD_API_KEY") ) # 从DATA_DIR读取文件 files = read_docs(data_dir = DATA_DIR)
我们从一个指定的目录中读取文档,使用解析器的get_images()
方法解析文档,并使用解析器的get_json_result()
方法获取图像字典。随后,提取节点并使用retrieve_nodes()
方法将它们发送到大语言模型,以根据整个文档为其分配上下文。解析这份60页的文档,包括获取图像字典,花费了5分钟34秒的时间,这是一次性的过程。
print("解析中,请稍候...") json_results = parser.get_json_result(files) print("正在下载图像...") images = parser.get_images(json_results, download_path=image_dir) print("获取节点信息...")
报告的第4页(资料来源:移民统计数据,点击链接查看原文件)
json_results[0]["pages"][3]
在 json_results
列表中,第一个元素的 pages
字段的第四个条目。
报告中第四页的内容由JSON结果中的第一个节点表示(作者供图)。
上下文相关检索每个节点及其相关截图是由 _retrievenodes() 函数从解析后的 _jsonresults 中提取的。每个节点连同代码中的 doc 变量代表的所有节点一起被发送到 assigncontext() 函数。assigncontext() 函数使用一个从 来源 修改而来的提示模板 _CONTEXT_PROMPTTMPL 为每个节点添加简洁的上下文。这样,我们将元数据、Markdown 文本、上下文和纯文本整合到每个节点中。
以下代码展示了 _retrieve_nodes_()
函数的实现。两个辅助函数分别用于按页排序获取图像文件和获取图像页码。总体目标不是不单纯依赖原始文本生成最终答案,像简单的 RAG 一样,而是考虑元数据、Markdown 文本、上下文、原始文本以及检索节点(节点元数据中的图像链接)的完整图像(截图),以生成最终回复。
# 通过正则表达式从文件名中提取图像的页码 def get_img_page_number(file_name): match = re.search(r"-page-(\d+)\.jpg$", str(file_name)) if match: return int(match.group(1)) return 0 # 按页码顺序排列图像文件 def _get_sorted_image_files(image_dir): raw_files = [f for f in list(Path(image_dir).iterdir()) if f.is_file()] sorted_files = sorted(raw_files, key=get_img_page_number) return sorted_files # 用于上下文分割的模板提示 CONTEXT_PROMPT_TMPL = """ 您是一个专注于文档分析的人工智能助手。您的任务是为来自给定文档的文本片段提供简要的相关背景信息。 这是文档: <document> {document} </document> 这是我们想要在文档整体中定位的片段: <chunk> {chunk} </chunk> 请根据以下指导方针提供简短的上下文(2-3句话): 1. 确定片段中讨论的主要主题或概念。 2. 提及文档更广泛背景中的任何相关信息或比较。 3. 如有需要,请说明这些信息如何与文档的总体主题或目的相关。 4. 如有重要背景信息,包含任何关键人物、日期或百分比。 5. 不要用“这个片段讨论”或“这一部分提供”的短语,而是直接陈述上下文。 请给出简短的上下文,以便在此整体文档中定位此片段,以提高该片段的检索效果。 只回答上下文,不要回答其他内容。 上下文: """ CONTEXT_PROMPT = PromptTemplate(CONTEXT_PROMPT_TMPL) # 为每个片段生成上下文 def _assign_context(document: str, chunk: str, llm) -> str: prompt = CONTEXT_PROMPT.format(document=document, chunk=chunk) response = llm.complete(prompt) context = response.text.strip() return context # 创建带有上下文的文本节点 def retrieve_nodes(json_results, image_dir, llm) -> List[TextNode]: nodes = [] for result in json_results: json_dicts = result["pages"] document_name = result["file_path"].split('/')[-1] docs = [doc["md"] for doc in json_dicts] # 提取文本 image_files = _get_sorted_image_files(image_dir) # 提取图像, # 将所有文档文本连接起来创建完整的文档文本 document_text = "\n\n".join(docs) for idx, doc in enumerate(docs): # 为每个片段生成上下文 context = _assign_context(document_text, doc, llm) # 将上下文与原始片段结合 contextualized_content = f"{context}\n\n{doc}" # 创建包含上下文内容的文本节点 chunk_metadata = {"page_num": idx + 1} chunk_metadata["image_path"] = str(image_files[idx]) chunk_metadata["parsed_text_markdown"] = docs[idx] node = TextNode( text=contextualized_content, metadata=chunk_metadata, ) nodes.append(node) return nodes # 获取文本节点 text_node_with_context = retrieve_nodes(json_results, image_dir, llm)报告的第一页(作者提供)
这是报告第一页的节点描述。
带有上下文和元数据的节点,作者提供的节点
使用BM25算法和重排序来增强检索的上下文相关性所有包含元数据、纯文本、Markdown 文本和上下文信息的节点都被索引到向量数据库中。为这些节点创建了 BM25 索引,并将它们保存为 pickle 文件用于查询推断。处理后的节点也将被保存以便后续使用(_text_node_withcontext.pkl)。
# 创建向量存储索引对象 index = VectorStoreIndex(text_node_with_context, embed_model=embed_model) index.storage_context.persist(persist_dir=output_dir) # 将持久化存储到 output_dir # 构建BM25模型 documents = [node.text for node in text_node_with_context] tokenized_documents = [doc.split() for doc in documents] bm25 = BM25Okapi(tokenized_documents) # 保存BM25模型和text_node_with_context数据 with open(os.path.join(output_dir, 'tokenized_documents.pkl'), 'wb') as f: pickle.dump(tokenized_documents, f) # 保存分词文档数据 with open(os.path.join(output_dir, 'text_node_with_context.pkl'), 'wb') as f: pickle.dump(text_node_with_context, f) # 保存text_node_with_context数据
我们现在可以开始初始化查询引擎,通过以下流程提出查询。但在那之前,设置了以下提示来指导LLM生成最终响应。初始化一个多模态的LLM(例如gpt-4o-mini)来生成最终响应。此提示可根据需要进行调整。
# 定义QA提示模板 RAG_PROMPT = """\ 下面我们给出了两种不同格式的文档解析文本以及图像。 --------------------- {context_str} --------------------- 仅依据上下文信息回答问题,不考虑先前的知识。通过分析解析的Markdown文本、纯文本以及相关图像生成答案。特别地,仔细分析图像以寻找所需的信息。 将答案格式化为合适的形式(如项目符号列表、部分/子部分、表格等)。 基于上下文提供的信息,给出答案所在的页码和文档名称。 问题: {query_str} 答案: """ PROMPT = PromptTemplate(RAG_PROMPT) # 初始化多模态大语言模型 MM_LLM = OpenAIMultiModal(model="gpt-4o-mini", temperature=0.0, max_tokens=16000)在查询引擎中整合整个流水线
以下_QueryEngine_类实现了上述工作流。BM25搜索中设置的节点数量(如_topn_bm25\)和需要重排的结果数量(如_topn\)可根据需要进行调整。在GitHub代码中切换_best_match_25_和_re_ranking_变量,可以启用或禁用BM25搜索和重排功能。
以下是由 QueryEngine 类实现的总体工作流程。
# 定义QueryEngine类,整合所有方法 class QueryEngine(CustomQueryEngine): # 公共属性 qa_prompt: PromptTemplate multi_modal_llm: OpenAIMultiModal node_postprocessors: Optional[List[BaseNodePostprocessor]] = None # 使用PrivateAttr定义私有属性 _bm25: BM25Okapi = PrivateAttr() _llm: OpenAI = PrivateAttr() _text_node_with_context: List[TextNode] = PrivateAttr() _vector_index: VectorStoreIndex = PrivateAttr() def __init__( self, qa_prompt: PromptTemplate, bm25: BM25Okapi, multi_modal_llm: OpenAIMultiModal, vector_index: VectorStoreIndex, node_postprocessors: Optional[List[BaseNodePostprocessor]] = None, llm: OpenAI = None, text_node_with_context: List[TextNode] = None, ): super().__init__( qa_prompt=qa_prompt, retriever=None, multi_modal_llm=multi_modal_llm, node_postprocessors=node_postprocessors ) self._bm25 = bm25 self._llm = llm self._text_node_with_context = text_node_with_context self._vector_index = vector_index def custom_query(self, query_str: str): # 准备查询包 query_bundle = QueryBundle(query_str) bm25_nodes = [] if best_match_25 == 1: # 如果选择了BM25检索 # 使用BM25检索节点 query_tokens = query_str.split() bm25_scores = self._bm25.get_scores(query_tokens) top_n_bm25 = 5 # 设置要检索的节点数量 # 获取BM25得分最高的索引 top_indices_bm25 = bm25_scores.argsort()[-top_n_bm25:][::-1] bm25_nodes = [self._text_node_with_context[i] for i in top_indices_bm25] logging.info(f"BM25节点检索完成:{len(bm25_nodes)}") else: logging.info("未选择BM25检索。") # 使用向量检索从向量存储检索节点 vector_retriever = self._vector_index.as_query_engine().retriever vector_nodes_with_scores = vector_retriever.retrieve(query_bundle) # 设置要检索的节点数量 top_n_vectors = 5 # 如需调整,请修改此值 # 获取前n个节点 top_vector_nodes_with_scores = vector_nodes_with_scores[:top_n_vectors] vector_nodes = [node.node for node in top_vector_nodes_with_scores] logging.info(f"向量节点检索完成:{len(vector_nodes)}") # 合并节点并去除重复项 all_nodes = vector_nodes + bm25_nodes unique_nodes_dict = {node.node_id: node for node in all_nodes} unique_nodes = list(unique_nodes_dict.values()) logging.info(f"去重后的节点数量:{len(unique_nodes)}") nodes = unique_nodes if re_ranking == 1: # 如果选择了重新排序 # 使用Cohere Re-ranking对结果重新排序 documents = [node.get_content() for node in nodes] max_retries = 3 for attempt in range(max_retries): try: reranked = cohere_client.rerank( model="rerank-english-v2.0", query=query_str, documents=documents, top_n=3 # 返回前3个重新排序的节点 ) break except CohereError as e: if attempt < max_retries - 1: logging.warning(f"错误发生:{str(e)}。等待60秒后重试,尝试{attempt + 1}/{max_retries}") time.sleep(60) # 重试前等待60秒 else: logging.error("错误发生,重试次数已达到最大。继续处理,不进行重新排序。") reranked = None break if reranked: reranked_indices = [result.index for result in reranked.results] nodes = [nodes[i] for i in reranked_indices] else: nodes = nodes[:3] # 回退到前3个节点 logging.info(f"重新排序后的节点数量:{len(nodes)}") else: logging.info("未选择重新排序。") # 限制和筛选节点内容以生成上下文字符串 max_context_length = 16000 # 根据需要调整 current_length = 0 filtered_nodes = [] # 初始化分词器 from transformers import GPT2TokenizerFast tokenizer = GPT2TokenizerFast.from_pretrained("gpt2") for node in nodes: content = node.get_content(metadata_mode=MetadataMode.LLM).strip() node_length = len(tokenizer.encode(content)) logging.info(f"节点ID:{node.node_id},内容长度(令牌):{node_length}") if not content: logging.warning(f"节点ID:{node.node_id}的内容为空,跳过。") continue if current_length + node_length <= max_context_length: filtered_nodes.append(node) current_length += node_length else: logging.info(f"达到最大上下文长度,节点ID:{node.node_id}") break logging.info(f"过滤后的节点数量:{len(filtered_nodes)}") # 生成上下文字符串 ctx_str = "\n\n".join( [n.get_content(metadata_mode=MetadataMode.LLM).strip() for n in filtered_nodes] ) # 从节点关联的图片创建图片节点 image_nodes = [] for n in filtered_nodes: if "image_path" in n.metadata: image_nodes.append( NodeWithScore(node=ImageNode(image_path=n.metadata["image_path"])) ) else: logging.warning(f"节点ID:{n.node_id}缺少'image_path'元数据。") logging.info(f"创建的图片节点数量为:{len(image_nodes)}") # 为LLM准备提示 fmt_prompt = self.qa_prompt.format(context_str=ctx_str, query_str=query_str) # 使用多模态LLM解释图片并生成响应 llm_response = self.multi_modal_llm.complete( prompt=fmt_prompt, image_documents=[image_node.node for image_node in image_nodes], max_tokens=16000 ) logging.info(f"LLM响应生成完成。") # 返回最终响应 return Response( response=str(llm_response), source_nodes=filtered_nodes, metadata={ "text_node_with_context": self._text_node_with_context, "image_nodes": image_nodes, }, ) # 使用BM25、Cohere Re-ranking和Query Expansion初始化查询引擎 query_engine = QueryEngine( qa_prompt=PROMPT, bm25=bm25, multi_modal_llm=MM_LLM, vector_index=index, node_postprocessors=[], llm=llm, text_node_with_context=text_node_with_context ) print("任务完成")
使用 OpenAI 模型,尤其是 gpt-4o-mini,的一个优势是成本大幅降低,特别是在上下文分配和查询推断运行方面,并且上下文分配时间也更短。尽管 OpenAI 和 Anthropic 的基础层级都会很快达到 API 调用的最大速率限制,但在 Anthropic 的基础层级中重试时间可能较长且不固定。仅对这文档的前20页进行上下文分配,使用 claude-3–5-sonnet-20240620 大约花费了 170 秒,并且成本为 20 美分(输入和输出令牌)。相比之下,gpt-4o-mini 的输入令牌成本约为 Claude 3.5 Sonnet 的 1/20,输出令牌成本约为其 1/25。OpenAI 宣称已经实现了提示缓存,可以自动缓存所有 API 调用中的重复内容。
相比之下,使用_gpt-4o-mini_将上下文分配给整个文档(共60页)中的节点,大约用了193秒完成,没有重试。
实现 _QueryEngine_
类之后,可以按照下面的方式运行查询推断:
original_query = """2023年,芬兰移民局向哪些国家的公民发放的第一居留许可数量最多? 哪个国家获得了芬兰移民局发放的第一居留许可数量最多?""" response = query_engine.query(original_query) display(Markdown(str(response)))
这里是你查询的Markdown格式的回复。
对查询的回复(图片由作者提供)
查询结果提到的页面如下。
以下查询中引用的页面之一(第9页)。信息用红色矩形框标注(来源:移民统计数据)
现在让我们比较基于gpt-4o-mini的RAG(LlamaParse premium、上下文检索、BM25 和重新排名)与基于Claude的RAG(LlamaParse premium 和上下文检索)的性能。我还实现了一个简单的基线RAG,该RAG可以在GitHub的笔记本中找到。以下是需要比较的三个RAG。
为了简单起见,我们将这些RAG分别称为RAG0、RAG1和RAG2。以下是报告中的三页内容,我从每一页中向每个RAG提出了一个问题。红色矩形框出的区域显示了正确答案来源或真实答案。
文档的第4页中(来源:移民统计数据)
文档的第12页(来源:移民相关数据)
文档的第20页中(来源:移民统计数据)(原文链接:https://emn.fi/wp-content/uploads/EMN_maahanmuuton-tunnusluvut_2023-EN-1.pdf)
以下是每个问题的三个RAGs(建议、评估、指导)的回复。
对比基本RAG、采用Claude的CMRAG和采用gpt-4o-mini的CMRAG(图片由作者提供)
可以看出,RAG2表现非常好。我们看到,对于第一个问题,RAG0给出了错误的答案,因为问题是关于一张图片的问题。RAG1和RAG2都正确回答了这个问题。对于另外两个问题,RAG0无法给出任何答案。相比之下,RAG1和RAG2正确回答了这些问题。
总的来说,在很多情况下,由于集成了BM25、重排序和更好的提示,RAG2的表现不输于甚至优于RAG1。它提供了一种性价比高的上下文和多模态RAG解决方案。在该流程中,可以考虑假设性文档嵌入(hyde)或查询扩展。同样可以探索的是开源嵌入模型(如all-MiniLM-L6-v2)和/或轻量级LLM(如_gemma2_或phi-3-small),以进一步降低成本。
如果你喜欢这篇文章,请点个赞,并关注我在Medium和/或领英的动态。
GitHub请查看我的仓库里的完整代码示例
GitHub - umairalipathan1980/Multimodal-contextual-RAG: 多模态上下文RAG项目。通过创建新问题,你可以为umairalipathan1980/Multimodal-contextual-RAG的发展做出贡献。github.com