多模态RAG应用开发实战演练 原创
本文将介绍如何基于高级解析、语义和关键字搜索以及重排序技术开发支持上下文检索的多模态RAG应用系统。
引言
当下,所有大型语言模型(LLM)都存在一个知识截止日期的问题,即它们无法回答针对其知识库中不存在的特定数据的查询。例如,LLM无法回答有关公司去年会议纪要数据的查询。另一方面,LLM还容易产生幻觉,并提供看似合理的错误答案。
为了克服这个问题,检索增强生成(RAG)解决方案越来越受欢迎。RAG的主要思想是将外部文档整合到大型语言模型中,并指导其行为仅从外部知识库中回答问题。具体地说,这是通过将文档分块为更小的块,计算每个块的嵌入(数值表示),然后将嵌入作为索引存储在专门的向量数据库中来实现的。
RAG工作流程示意图——查询被转换为嵌入,通过检索模型与向量数据库匹配,并与检索到的数据相结合,最终通过大型语言模型产生响应。
上下文检索RAG
将用户的查询与向量数据库中的小块进行匹配的过程通常效果良好;然而,它还存在以下问题:
- 一个问题的答案可能需要多个彼此相距甚远的块。由于上下文丢失,无法找到所有相关的块。例如,考虑一个法律文件的问题:“阿尔法和贝塔公司之间终止合伙关系的条件是什么?”文件中的一个部分可能是“协议可能会在特定条件下终止”。然而,由于缺乏任何上下文信息(没有公司名称),在检索过程中无法选择此块。
- 对于某些问题,传统的最佳匹配搜索比语义搜索更有效,尤其是对于精确匹配而言。例如,在电子商务文档中,通过语义搜索方法对查询“什么是产品ID ZX-450?”的答案可能会带来有关多个产品的信息,而缺少确切的“ZX-450”产品。
- 从向量数据库检索到的信息被转发到LLM,LLM根据查询生成最终答案。在此过程中,LLM必须确定最合适的块来生成最终答案。检索到的块太多可能会导致响应中出现不相关的信息。因此,LLM必须有一个排序机制。
为了应对这些问题,Anthropic公司最近引入了一种向每个块添加上下文的方法;与原始RAG相比,该方法的性能有了显著提高。在将文档拆分为块后,该方法首先将块与整个文档作为上下文一起发送到LLM,为每个块分配一个简短的上下文。随后,上下文附加的块被保存到向量数据库中。它们进一步使用bm25检索器将上下文分块与最佳匹配相结合,该检索器使用bm25方法搜索文档,并使用一个重新排序模型,该模型根据相关性为每个检索到的块分配评分。
具有上下文检索的多模态RAG
尽管性能有了显著提高,但Anthropic公司仅证明了这些方法对文本类型数据的适用性。但当今世界中,许多文档中丰富的信息的来源包括图像(图形、图形)和复杂的表格,等等。如果我们只解析文档中的文本,我们将无法深入了解文档中的其他模式。因此,包含图像和复杂表格的文档需要高效的解析方法,这不仅需要从文档中正确提取它们,还需要理解它们。
使用Anthropic公司的最新模型(claude-3-5-connect-20240620)为文档中的每个块分配上下文在大型文档的情况下可能会涉及高成本,因为它涉及将整个文档与每个块一起发送。尽管Claude模型的提示缓存技术可以通过在API调用之间缓存频繁使用的上下文来显著降低这一成本,但其成本仍远高于OpenAI公司的成本高效模型,如gpt-4o-mini。
本文旨在探讨针对上述Anthropic公司方法的进一步扩展,如下所示:
- 使用LlamaParse将所有内容(从文本到表格再到图像)提取到结构良好的markdown格式的文档中。
- 通过节点解析器将文档解析为节点,而不是使用文本拆分器将文档拆分为块。这不仅涉及拆分文本,还涉及理解文档的结构、语义和元数据等任务。
- OpenAI公司极具成本效益的大型语言模型gpt-4o-mini和嵌入模型text-embedding-3-small用于为每个节点分配上下文、生成最终响应和计算节点的嵌入。
在了解了Anthropic公司关于上下文检索的博客文章之后,我在GitHub链接上找到了OpenAI公司的部分实现。然而,它使用传统的分块和LlamaParse方法,没有最近推出的高级模式。我发现Llamaparse的高级模式在提取文档中的不同结构方面非常有效。
Anthropic公司的上下文检索实现也可以在GitHub上找到,它使用了LlamaIdex抽象;然而,它没有实现多模态解析。在撰写本文时,LlamaIdex提供了一个更新的实现,它使用了多模态解析和上下文检索。该实现使用了Anthropic公司的LLM(claude-3–5-connect-2024062)和Voyage公司的嵌入模型(Voyage-3)。然而,它们并没有像Anthropic公司的博客文章中提到的那样探索BM25(Best Matching 25)排序算法和重排序(Reranking)技术。
本文讨论的上下文检索实现是一种低成本、多模态的RAG解决方案,通过BM25搜索和重新排序提高了检索性能。还将这种基于上下文检索的多模态RAG(CMRAG)的性能与基本RAG和LlamaIdex的上下文检索实现进行了比较。
下面4个链接中重新使用了这其中的一些功能,并进行了必要的修改。
4.https://github.com/lesteroliver911/contextual-doc-retrieval-opneai-reranker?tab=readme-ov-file
此实现的源代码可在GitHub上获得。
本文中用于实现基于上下文检索的多模态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=True
vendor_multimodal_model_name="openai-gpt-4o"
vendor_multimodal_api_key=sk-proj-xxxxxx
)
在这种模式下,会对文档的每一页进行截图,然后将截图发送到多模态模型,并附上提取标记的指令。每页的标记结果被合并到最终输出中。
最近的LlamaParse高级模式提供了先进的多模态文档解析支持,能够将文本、表格和图像提取到结构良好的标记中,同时显著减少了缺失的内容和幻觉。它可以通过在Llama云平台创建一个免费账号并获得API密钥来使用。免费计划提供每天解析1000个页面。
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_key=os.getenv("LLAMA_CLOUD_API_KEY")
)
files = read_docs(data_dir = 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_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)
现在,我们可以初始化一个查询引擎,使用以下管道进行查询。但在此之前,设置以下提示以指导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 related
image. 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)
#初始化多模态LLM
MM_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: 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):
# 准备查询bundle
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 nodes retrieved: {len(bm25_nodes)}")
else:
logging.info("BM25 not selected.")
#从向量存储中使用基于向量的检索技术进行节点检索
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"Vector nodes retrieved: {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"Unique nodes after deduplication: {len(unique_nodes)}")
nodes = unique_nodes
if re_ranking == 1: #如果选择使用重排序算法
# 使用Cohere公司的重排序算法对组合后的结果进行重排序
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 # top-3 个重排序节点
)
break
except 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 = 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"Nodes after re-ranking: {len(nodes)}")
else:
logging.info("Re-ranking not selected.")
# 针对上下文字符串限制并过滤节点内容
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"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.")
continue
if current_length + node_length <= max_context_length:
filtered_nodes.append(node)
current_length += node_length
else:
logging.info(f"Reached max context length with Node ID: {node.node_id}")
break
logging.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响应。
对查询的响应(图片由作者提供)
查询响应中引用的页面如下:
上述查询中引用的一页(第9页)。提取的信息显示在红色矩形中(来源:移民关键数据)
现在,让我们比较一下基于gpt-4o-mini模型的RAG(LlamaParse高级模式+上下文检索+BM25+重排序)和基于Claude模型的RAG。我还实现了一个简单的基础级别的RAG,可以在GitHub的笔记本中找到。以下是要比较的三个RAG。
1. LlamaIndex中的简单RAG使用SentenceSplitter将文档分割成块(chunk_size=800,chunk_overlap=400),创建向量索引和向量检索。
2. CMRAG(claude-3–5-connect-20240620,voya-3)——LlamaParse高级模式+上下文检索。
3. CMRAG(gpt-4o-mini,text-embedding-3-small)——LlamaParse高级模式+上下文检索+BM25+重排序。
为了简单起见,我们将这些RAG分别称为RAG0、RAG1和RAG2。以下是报告中的三页,我向每个RAG提出了三个问题(每页一个问题)。红色矩形突出显示的区域显示了基本事实或正确答案的来源。
文件第4页(来源:移民关键数据)
文件第12页(来源:移民关键数据)
文件第20页(来源:移民关键数据)
以下是对每个问题的三个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代码仓库:https://github.com/umairalipathan1980/Multimodal-contextual-RAG.git?source=post_page-----d1965b8ab00c--------------------------------
译者介绍
朱先忠,51CTO社区编辑,51CTO专家博客、讲师,潍坊一所高校计算机教师,自由编程界老兵一枚。
原文标题:Integrating Multimodal Data into a Large Language Model,作者:Umair Ali Khan