我在LlamaIndex和LangChain框架学习中,都有玩过RAG。最近准备开个RAG进阶系统,一起学习AI, 一起成长。欢迎点赞,留言交流。
前言
RAG是一种自然语言处理技术,它将检索(向量数据库)和生成式人工智能模型的能力,有效提高信息检索质量,我们称之为检索增强生成技术。
ChatGPT
是聊天机器人,那么基于大模型的文档
聊天机器人,就是RAG应用了。
Naive RAG
Naive RAG 指最本的检索生成,包括文档分块、嵌入(Embedding)、并基于用户提出的问题进行语义相似性搜索来生成检索内容。本文我们将在Naive RAG的基础上,升华我们对RAG的认识和能力。
Naive RAG的优点是简单,缺点是性能比较差,质量也不高,本系列让我们一起来学习Advanced RAG。
Semi-Structured Data
Semi Structured Data
半结构化数据的RAG是我们Advanced RAG
学习的第一篇。那么什么是半结构化数据呢?这个应该是相当结构化数据来说的,我们先来理下这些概念。
- 结构化数据
信息有预定义的结构格式。举子例子,在Mysql中,数据表的行与列分别对数据进行了预定义,这就是典型的结构化数据。它的优点是非常易于搜索与分析。
- 非结构化数据
没有特定的格式和结构,主要由文字、图片、多媒体等构成。非结构化数据不太好统一处理,但是这些数据又是RAG需要检索的重点内容,十分具有挑战性。
- 半结构化数据
介于结构化和非结构化之间,它由两种格式的数据混合在一起。那么我们怎么来处理呢?结构化数据我们可以用SQL等DSL语言快速解决问题,非结构化数据我们可以拆分,再embedding检索。但是如果我们的数据是结构化和非结构化混合的半结构化数据。文档的拆分就会破坏表结构, 同时表格和图片要做向量化,然后做语义查询。
PDF文档就是半结构化数据的例子。它里面包含文字、表格、图片等。等下,我们就来挑战一下怎么基于半结构化数据构建RAG。主要会用到以下几个组件:unstructured包,帮助我们自定义管道或流来处理文字、图表、图片这些元素。还有就是LangChain,我们用它来搭建整个RAG应用。向量数据库我们用的是chromadb。
Nvidia 股权变量声明
等下demo里处理的半结构化数据来自Nvida的一份股权变更声明。大家可以从下面的截图看到它的内容,比较小,方便展示结构化的图表和非结构化的文字,我们打理过后的效果。
实战
- 安装依赖包
css
!pip install langchain unstructured[all-docs] pydantic lxml openai chromadb tiktoken -q -U
langchain是RAG应用开发框架、unstructured支持半结构或非结构化数据处理、pydantic可以做数据验证和解析转换、lxml做xml解析、ooenai是大模型、chromadb是向量数据库、tiktoken可以统计token数量。
- 下载 PDF文件,命名为statement_of_changes.pdf
arduino
!wget -o statement_of_changes.pdf https://d18rn0p25nwr6d.cloudfront.net/CIK-0001045810/381953f9-934e-4cc8-b099-144910676bad.pdf
- 安装poppler-utils和tesseract-ocr
这两个包是系统包,用于PDF文件内容的抽取以及字符的识别,安装命令会因系统不一样有所区别(mac/windows/linux)
arduino
!apt-get install poppler-utils tesseract-ocr
- 准备LLM ,这里我们使用gpt4
lua
import os
os.environ["OPENAI_API_KEY"] = ""
- 编码
首先,让我们使用unstructured库提供的partition_pdf将PDF文档中的内容分成不同类型的元素。
ini
from typing import Any
from pydantic import BaseModel
from unstructured.partition.pdf import partition_pdf
raw_pdf_elements = partition_pdf(
filename = "statement_of_changes.pdf",
extract_images_in_pdf=False,
infer_table_structure=True,
# 基于标题来划分chunk
chunking_strategy = "by_title",
max_characters=4000,
new_after_n_chars=3000,
combine_text_under_n_chars=2000,
image_output_dir_path="."
)
我们来聊下partition_pdf函数里的几个参数的意义。extract_images_in_pdf 表示是否要抽取pdf里的图片,这里不处理,因为当前pdf里面没有图片。infer_table_structure 表示是否来抽取表格数据,这里是处理。从代码运行看,它会触发一些模型文件并加载。从下图可以看到,使用的是microsoft/table-transformer-struct-recognition模型,需要使用到GPU资源,否则就非常慢。
- 将元素分类
ini
category_counts = {}
for element in raw_pdf_elements:
category = str(type(element))
if category in category_counts:
category_counts[category] += 1
else:
category_counts[category] = 1
unique_categories = set(category_counts.keys())
category_counts
通过遍历raw_pdf_elements,我们得到每个elment的类型。set 帮助我们去重,拿到了所有的类别,category_counts字典包含了每个类别的数量信息。
从上图可以看到,CompositionElement有5个,Table有4个。接下来,我们可以根据这些类型,将不同的内容放到不同的处理容器中,完成分拣操作。
python
class Element(BaseModel):
type: str
text: Any
table_elements = []
text_elements = []
for element in raw_pdf_elements:
if "unstructured.documents.elemnts.Table" in str(type(element)):
table_elements.append(Element(type="table", text=str(element)))
elif "unstructured.documents.elments.CompositeElement" in str(type(element)):
text_elements.append(Element(type="text", text=str(element)))
print(len(table_elements))
print(len(text_elements))
打印是4和5,我们再来打印下结构化的table。
从打印结果,可以看出所对应的表格,识别的很靠谱。table_elements[0]对应的是下图的这块,我们了解了unstructured是如何解析table的。
Chain
LangChain构建一条Chain来处理数据了。
ini
from langchain.chat_models import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain.schema.output_parser import StrOutputParser
# 对文本和表格做摘要
prompt_text = """
You are responsible for concisely summarizing table or text chunk.
{element}
"""
prompt = ChatPromptTemplate.from_template(prompt_text)
model = ChatOpenAI(temperature=0,model="gpt-4")
summarize_chain={"element": lambda x: x} | prompt | model | StrOutputParser
ini
# 对每个element都做, 并发是5
# 给表格做摘要, 表格也是文本
tables = [i.text for i in table_elements]
table_summarizes = summarize_chain.batch(tables, {"max_concurrency": 5})
# 给文本做摘要
texts = [i.text for i in text_elments]
text_summarizes = summarize_chain.batch(texts, {"max_concurrency": 5})
接下来,我们再使用MultiVectorRetriever
构建检索链,它会将摘要信息和原始文本信息以父子关系一对一关联起来。这样即可以使用到原始文本,也可以使用到摘要信息,帮助我们提高RAG的质量。
ini
# 生成唯一id
import uuid
# 嵌入,文本数据转向量数据
from langchain.embeddings import OpenAIEmbeddings
# Document 文档
from langchain.schema.document import Document
# 内存存储
from langchain.storage import InMemoryStore
# Chroma向量数据库
from langchain.vectorstores import Chroma
# 声明向量数据库实例
vectorstore = Chroma(collection_name="summaries", embedding_function=OpenAIEmbeddings())
store = InMemoryStore()
# 通过key 将父子文档关联起来
id_key="doc_id"
# 检索器
retriever = MultiVectorRetriever(
vectorstore = vectorstore,
docstore = store,
id_key=id_key
)
# 对每个文本生成文本ID
doc_ids = [str(uuid.uuid4()) for _ in texts]
# s是摘要,metadata是原数据,里面包含id_key
summary_texts = [
Document(page_content=s, metadata={id_key:doc_ids[i]})
for i, s in enumerate(text_summaries)
]
# 将摘要放进向量数据库,会做Embedding
retriever.vectorstore.add_documents(summary_texts)
# 将原文放入内存存储
retriever.docstore.mset(list(doc_ids, texts))
# 表格也来做一下
table_ids = [str(uuid.uuid4()) for _ in tables]
summary_tables = [
Document(page_content=s, metadata={id_key:table_ids[i]})
for i, s in enumerate(table_summaries)
]
retriever.vectorstore.add_documents(summary_tables)
retriever.docstore.mset(list(zip(table_ids, tables)))
集成
ini
from langchain.schema.runnable import RunnablePassthrough
template = """Answer the question based only on the following context, which can include text and tables:
{context}
Question: {question}
"""
prompt = ChatPromptTemplate.from_template(template)
## LLM
model = ChatOpenAI(temperature = 0, model="gpt-4")
chain = (
{"context": retriever, "question": RunnablePassthrough()}
| prompt
| model
| StrOutputParser()
)
执行
我们基于文档中的表格数据进行提问,在某个时刻对某只股票做了交易,或变更,最后还有受益人。
arduino
chain.invoke("How many stocks were disposed?Who is the beneficial owner?")
注意,我们这里使用的是gpt4, 大家可以切换成gpt-3.5-turbo, 你会发现就不那么work了。
总结
- MultiVectorRetriever
- unstructured
- chromadb 和 InMemoryStore