企业宣传,产品推广,广告招商,广告投放联系seowdb

多模态RAG应用开发实战演练

引言

为了克服这个问题,检索增强生成(RAG)解决方案越来越受欢迎。RAG的主要思想是将外部文档整合到大型语言模型中,并指导其行为仅从外部知识库中回答问题。具体地说,这是通过将文档分块为更小的块,计算每个块的嵌入(数值表示),然后将嵌入作为索引存储在专门的向量数据库中来实现的。

RAG工作流程示意图——查询被转换为嵌入,通过检索模型与向量数据库匹配,并与检索到的数据相结合,最终通过大型语言模型产生响应。

上下文检索RAG

将用户的查询与向量数据库中的小块进行匹配的过程通常效果良好;然而,它还存在以下问题:

为了应对这些问题,Anthropic公司最近引入了 一种向每个块添加上下文的方法 ;与原始RAG相比,该方法的性能有了显著提高。在将文档拆分为块后,该方法首先将块与整个文档作为上下文一起发送到LLM,为每个块分配一个简短的上下文。随后,上下文附加的块被保存到向量数据库中。它们进一步使用 bm25检索器 将上下文分块与最佳匹配相结合,该检索器使用bm25方法搜索文档,并使用一个重新排序模型,该模型根据相关性为每个检索到的块分配评分。

具有上下文检索的多模态RAG

尽管性能有了显著提高,但Anthropic公司仅证明了这些方法对文本类型数据的适用性。但当今世界中,许多文档中丰富的信息的来源包括图像(图形、图形)和复杂的表格,等等。如果我们只解析文档中的文本,我们将无法深入了解文档中的其他模式。因此,包含图像和复杂表格的文档需要高效的解析方法,这不仅需要从文档中正确提取它们,还需要理解它们。

使用Anthropic公司的最新模型(claude-3-5-connect-20240620)为文档中的每个块分配上下文在大型文档的情况下可能会涉及高成本,因为它涉及将整个文档与每个块一起发送。尽管 Claude模型的提示缓存技术 可以通过在API调用之间缓存频繁使用的上下文来显著降低这一成本,但其成本仍远高于OpenAI公司的成本高效模型,如gpt-4o-mini。

本文旨在探讨针对上述Anthropic公司方法的进一步扩展,如下所示:

在了解了Anthropic公司关于上下文检索的 博客文章 之后,我在 GitHub链接 上找到了OpenAI公司的部分实现。然而,它使用传统的分块和LlamaParse方法,没有最近推出的 高级模式 。我发现Llamaparse的高级模式在提取文档中的不同结构方面非常有效。

Anthropic公司的上下文检索实现也可以在GitHub上找到,它使用了LlamaIdex抽象;然而,它没有实现多模态解析。在撰写本文时,LlamaIdex提供了一个更新的 实现 ,它使用了多模态解析和上下文检索。该实现使用了Anthropic公司的LLM(claude-3–5-connect-2024062)和Voyage公司的嵌入模型()。然而,它们并没有像Anthropic公司的博客文章中提到的那样探索BM25(Best Matching 25)排序算法和重排序(Reranking)技术。

本文讨论的上下文检索实现是一种低成本、多模态的RAG解决方案,通过BM25搜索和重新排序提高了检索性能。还将这种基于上下文检索的多模态RAG(CMRAG)的性能与基本RAG和LlamaIdex的上下文检索实现进行了比较。

下面4个链接中重新使用了这其中的一些功能,并进行了必要的修改。

此实现的源代码可在上获得。

本文中用于实现基于上下文检索的多模态RAG(以下简称“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 = LlamaParse(use_vendor_multimodal_model=Truevendor_multimodal_model_name="openai-gpt-4o"vendor_multimodal_api_key=sk-proj-xxxxxx)

在这种模式下,会对文档的每一页进行截图,然后将截图发送到多模态模型,并附上提取标记的指令。每页的标记结果被合并到最终输出中。

最近的 LlamaParse高级模式 提供了先进的多模态文档解析支持,能够将文本、表格和图像提取到结构良好的标记中,同时显著减少了缺失的内容和幻觉。它可以通过在 Llama云平台 创建一个免费账号并获得API密钥来使用。免费计划提供每天解析1000个页面。

LlamaParse高级模式的使用方式如下:

from llama_parse import LlamaParseimport 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 filesparser = LlamaParse(result_type="markdown",premium_mode=True,api_key=os.getenv("LLAMA_CLOUD_API_KEY"))files = read_docs(data_dir =>

在上述代码中,我们首先从指定目录读取文档,使用解析器的get_json_result()方法解析文档,并使用解析器的get_images()方法获取图像字典。随后,提取节点并将其发送到LLM,以使用retrieve_nodes()方法根据整个文档分配上下文。解析这份文档(60页),包括获取图像词典等内容,共计耗时5分34秒(一次性过程)。

print("Parsing...")json_results = parser.get_json_result(files)print("Getting image dictionaries...")images = parser.get_images(json_results, download_path=image_dir)print("Retrieving nodes...")
json_results[0]["pages"][3]

报告中的第四页由JSON结果的第一个节点表示(按作者排列的图像)

上下文检索

通过retrieve_nodes()函数从解析的josn_results中提取单个节点和相关图像(屏幕截图)。每个节点与所有节点(以下代码中的doc变量)一起被发送到_assign_context()函数。_assign_context()函数使用提示模板 context_prompt_TMPL (来自链接,并经过修改后采用)为每个节点添加简洁的上下文。通过这种方式,我们将元数据、标记文本、上下文和原始文本集成到节点中。

以下代码显示了retrieve_nodes()函数的实现。两个辅助函数_get_sorted_image_files()和get_img_page_number()分别按页面和图像的页码获取排序后的图像文件。总体目标不是像简单的RAG那样仅依赖原始文本来生成最终答案,而是考虑元数据、标记文本、上下文和原始文本,以及检索到的节点的整个图像(屏幕截图)(节点元数据中的图像链接)来生成最终响应。

# 针对文件名使用正则表达式获取图像所在的页码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 = """You are an AI assistant specializing in document analysis. Your task is to provide brief, relevant context for a chunk of text from the given document.Here is the document:<document>{document}</document>Here is the chunk we want to situate within the whole document:<chunk>{chunk}</chunk>Provide a concise context (2-3 sentences) for this chunk, considering the following guidelines:1. Identify the main topic or concept discussed in the chunk.2. Mention any relevant information or comparisons from the broader document context.3. If applicable, note how this information relates to the overall theme or purpose of the document.4. Include any key figures, dates, or percentages that provide important context.5. Do not use phrases like "This chunk discusses" or "This section provides". Instead, directly state the context.Please give a short succinct context to situate this chunk within the overall document to improve search retrieval of the chunk.Answer only with the succinct context and nothing else.Context:"""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)First page of the report (image by author)First page of the report (image by author)

添加了上下文和元数据的节点(图片由作者提供)

用BM25增强上下文检索并重新排序

所有具有元数据、原始文本、标记文本和上下文信息的节点都被索引到向量数据库中。节点的BM25索引被创建并保存在pickle文件中,用于查询推理。处理后的节点也会被保存,以供使用(text_node_with_context.pkl)。

# 创建向量存储牵引index = VectorStoreIndex(text_node_with_context, embed_model=embed_model)index.storage_context.persist(persist_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_contextwith 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)

现在,我们可以初始化一个查询引擎,使用以下管道进行查询。但在此之前,设置以下提示以指导LLM生成最终响应的行为。初始化多模态LLM(gpt-4o-mini)以生成最终响应。此提示可根据需要进行调整。

# 定义QA 提示模板RAG_PROMPT = """\Below we give parsed text from documents in two different formats, as well as the image.---------------------{context_str}---------------------Given the context information and not prior knowledge, answer the query. Generate the answer by analyzing parsed markdown, raw text and the relatedimage. Especially, carefully analyze the images to look for the required information.Format the answer in proper format as deems suitable (bulleted lists, sections/sub-sections, tables, etc.)Give the page's number and the document name where you find the response based on the Context.Query: {query_str}Answer: """PROMPT = PromptTemplate(RAG_PROMPT)#初始化多模态LLMMM_LLM = OpenAIMultiModal(model="gpt-4o-mini", temperature=0.0, max_tokens=16000)

在查询引擎中集成整个管道流程

本节中要介绍的QueryEngine类实现了上述完整的工作流程。BM25搜索中的节点数量(top_n_BM25)和重新排序器重新排序的结果数量(top_name)可以根据需要进行调整。通过切换GitHub代码中的best_match_25和re_ranking变量,可以选择或取消选择BM25搜索和重排序。

下面给出的是QueryEngine类实现的整体工作流程:

1.查找查询嵌入。

2.使用基于向量的检索从向量数据库中检索节点。

3.使用BM25搜索检索节点(如果选择使用该方法的话)。

4.结合BM25和基于向量的检索中的节点。查找节点的唯一数量(删除重复的节点)。

5.应用重排序对组合结果进行重排序(如果选中该方法的话)。在这里,我们使用Cohere公司的rerank-english-v2.0重新排序模型。您可以在Cohere公司的 网站 上创建一个账号,以获得试用版API密钥。

6.从与节点关联的图像创建图像节点。

7.根据解析的markdown文本创建上下文字符串。

8.将节点图像发送到多模态LLM进行解释。

9.通过将文本节点、图像节点描述和元数据发送到LLM来生成最终响应。

#定义类QueryEngine,把所有方法集成到一起class QueryEngine(CustomQueryEngine):# 公共属性qa_prompt: PromptTemplatemulti_modal_llm: OpenAIMultiModalnode_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 = bm25self._llm = llmself._text_node_with_context = text_node_with_contextself._vector_index = vector_indexdef custom_query(self, query_str: str):# 准备查询bundlequery_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 nodes retrieved: {len(bm25_nodes)}")else:logging.info("BM25 not selected.")#从向量存储中使用基于向量的检索技术进行节点检索vector_retriever = self._vector_index.as_query_engine().retrievervector_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"Vector nodes retrieved: {len(vector_nodes)}")# 把节点组合起来,并删除重复的节点all_nodes = vector_nodes + bm25_nodesunique_nodes_dict = {node.node_id: node for node in all_nodes}unique_nodes = list(unique_nodes_dict.values())logging.info(f"Unique nodes after deduplication: {len(unique_nodes)}")nodes = unique_nodesif re_ranking == 1:#如果选择使用重排序算法# 使用Cohere公司的重排序算法对组合后的结果进行重排序documents = [node.get_content() for node in nodes]max_retries = 3for attempt in range(max_retries):try:reranked = cohere_client.rerank(model="rerank-english-v2.0",query=query_str,documents=documents,top_n=3# top-3 个重排序节点)breakexcept CohereError as e:if attempt < max_retries - 1:logging.warning(f"Error occurred: {str(e)}. Waiting for 60 seconds before retry {attempt + 1}/{max_retries}")time.sleep(60)#重试前需要等待else:logging.error("Error occurred. Max retries reached. Proceeding without re-ranking.")reranked = Nonebreakif 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"Nodes after re-ranking: {len(nodes)}")else:logging.info("Re-ranking not selected.")# 针对上下文字符串限制并过滤节点内容max_context_length = 16000# 根据需要进行调整current_length = 0filtered_nodes = []#分词器初始化from transformers import GPT2TokenizerFasttokenizer = 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"Node ID: {node.node_id}, Content Length (tokens): {node_length}")if not content:logging.warning(f"Node ID: {node.node_id} has empty content. Skipping.")continueif current_length + node_length <= max_context_length:filtered_nodes.append(node)current_length += node_lengthelse:logging.info(f"Reached max context length with Node ID: {node.node_id}")breaklogging.info(f"Filtered nodes for context: {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"Node ID: {n.node_id} lacks 'image_path' metadata.")logging.info(f"Image nodes created: {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 response generated.")#返回结果响应值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_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("All done")

使用OpenAI公司提供的模型,特别是gpt-4o-mini的一个优点是上下文分配和查询推理运行的成本要低得多,上下文分配时间也要短得多。虽然OpenAI公司和Anthropic公司的基本层确实很快达到API调用的最大速率限制,但Anthropc公司的基本层中的重试时间各不相同,可能太长。使用claude-3–5-connect-20240620对本文档的前20页进行上下文分配过程,使用提示缓存大约需要170秒,成本为20美分(输入+输出词元)。然而,与Claude 3.5 Sonnet相比,gpt-4o-mini的输入词元大约便宜20倍,输出词元大约便宜25倍。OpenAI公司声称为重复内容实现了提示缓存,这对所有API调用都自动起作用。

相比之下,通过gpt-4o-mini向整个文档(60页)中的节点分配上下文大约在193秒内完成,没有任何重试请求。

实现QueryEngine类后,我们可以按如下方式运行查询推理:

original_query = """What are the top countries to whose citizens the Finnish Immigration Service issued the highest number of first residence permits in 2023?Which of these countries received the highest number of first residence permits?"""response = query_engine.query(original_query)display(Markdown(str(response)))

这是对此查询的markdown响应。

对查询的响应(图片由作者提供)

查询响应中引用的页面如下:

现在,让我们比较一下基于gpt-4o-mini模型的RAG(LlamaParse高级模式+上下文检索+BM25+重排序)和基于Claude模型的RAG。我还实现了一个简单的基础级别的RAG,可以在GitHub的笔记本中找到。以下是要比较的三个RAG。

1.LlamaIndex中的简单RAG使用SentenceSplitter将文档分割成块(chunk_size=800,chunk_overlap=),创建向量索引和向量检索。

2.CMRAG(claude-3–5-connect-20240620,voya-3)——LlamaParse高级模式+上下文检索。

3.CMRAG(gpt-4o-mini,text-embedding-3-small)——LlamaParse高级模式+上下文检索+BM25+重排序。

以下是对每个问题的三个RAG的回答。

基本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或phi3-small),使其更具成本效益。

有关本文示例中完整的源代码参考,请查看我的github代码仓库:

译者介绍

朱先忠,社区编辑,专家博客、讲师,潍坊一所高校计算机教师,自由编程界老兵一枚。

原文标题: Integrating Multimodal> 来源: 内容精选

© 版权声明
评论 抢沙发
加载中~
每日一言
不怕万人阻挡,只怕自己投降
Not afraid of people blocking, I'm afraid their surrender