无论你是使用 Coze 或 Dify 这样的低代码工具构建 RAG 系统,还是使用 LangChain 或 LlamaIndex 这样的开源框架进行编码,解析文件并读取其中的内容,都是整个嵌入、检索和生成(RAG)流程中至关重要的第一步。如果系统无法准确识别和解析文件,就无法为相应领域构建知识库,后续的 RAG 任务也就无从开展。
图 1.1:两个人坐在桌前讨论 RAG 系统
解析过程是否依赖文件类型?
在企业环境中,文件类型往往非常多样。不仅有 PDF、.doc / .docx 等常见格式,也可能存在一些不常见格式或专有格式。为了处理这种复杂性,需要为每一种文件类型建立清晰的解析策略,并选择合适的工具。应优先使用通用解析库或工具来覆盖常见文件格式。例如,Unstructured 工具就可以以统一方式处理同一目录下的多种文件格式。
图 1.2:使用 LangChain 和 LlamaIndex 对各种文件类型进行文档导入的流程
对于常见且重要的文件类型,例如 PDF,可以选择专门的工具,例如 PyPDF2、PyMuPDF、Marker、MinerU 和 PDFPlumber,来解析并提取所需内容。具体选择取决于应用场景,例如结构化数据提取、文本内容搜索、OCR 或图片提取等。
对于不太常见的文件类型,需要弄清楚生成该文件的工具、它的数据结构以及用途。例如,如果某个目录中包含一个 product_sales.jsonl 文件,你可能会从文件名推断出它是一个 JSON Lines 格式的产品销售日志。JSON Lines 是一种文本文件格式,其中每一行都包含一个独立的 JSON 对象,行与行之间没有逗号或其他分隔符。在解析时必须考虑这一点。
对于复杂文档,可以先将它们转换为一种更容易解析的中间格式。例如,可以先将 PPT 文件转换为 PDF 格式,再进行信息提取,因为 PDF 提取工具更丰富,可选方案更多。类似地,有时最好先统一将 PDF 文件转换成 Markdown 文档再处理,因为 Markdown 格式更加标准化,因此更容易将标题、段落和列表提取为纯文本。此外,由于大模型是在 Web 资源上训练出来的,它们天然更熟悉 Markdown 格式,这也更有利于模型理解。
此外,大模型也可以用来辅助解析结构化、半结构化和非结构化数据,尤其是在某些复杂场景中,例如多模态场景,它们能够取得非常好的效果。
在下一节中,我们将深入探讨如何把各种类型的文档解析成纯文本,并导入 RAG 系统。
使用 DataLoader 读取简单文本
我们先从最简单、最直观的 TXT 文件格式开始,解释如何读取文档。
图 1.3:一个记事本中的文本文件,详细描述了游戏故事章节和文化元素
LangChain 和 LlamaIndex 等框架提供了各种数据加载器,可以将文档解析为特定格式的数据对象。如果你不想使用这些框架,也可以选择 Unstructured 这类独立的文档解析工具。
使用 LangChain 读取 TXT 文件并生成文档对象
LangChain 的 TextLoader 类可以用来读取 TXT 文件,并将其解析成 LangChain 的 Document 数据对象。首先,确保已经安装 langchain、langchain-core 和 langchain_community 包。然后可以使用下面的代码通过 LangChain 读取 TXT 文件。完整代码可参考 github.com/PacktPublis…
from langchain_community.document_loaders import TextLoader
loader = TextLoader("data/black myth/ The setting of Black Myth Wukong.txt")
documents = loader.load()
print(documents)
输出如下:
[Document(metadata={'source': 'data/black myth/The setting of Black Myth Wukong.txt'}, page_content='The story of "Black Myth: Wukong" can be divided into 6 sections, namely "Fire Illuminates Black Clouds", "Wind Rises at Dusk", "Night Gives Birth to White Dew", "Curved Purple Mandarin Ducks", "Sunset in the Mortal World", and "Unfinished", and features two endings. The player's choices and experiences will affect the final outcome.\nAt the end of each section, two-dimensional and three-dimensional animated cutscenes are attached, showcasing and exploring the narrative and thematic elements in "Black Myth: Wukong".\nThe game's setting blends Chinese culture and natural landmarks. For example, the Dazu Rock Carvings in Chongqing, Xiaoxitian, Nanchan Temple, Tiebusi Temple, Guangsheng Temple, and the Stork Tower in Shanxi Province, etc., all appear in the game. The game also incorporates philosophical elements of Buddhism and Taoism.')]
在 LangChain 中,Document 对象是一个核心数据结构,用来表示从外部文件或其他数据源加载进来的文本内容。正如前面的代码所示,Document 对象主要包含以下两个属性:
metadata:存储与文档相关的元数据,例如文档来源路径、作者、日期等。
page_content:存储实际的文本内容,也就是文档的主体数据部分。
Alex:为什么我们需要使用这个 Document object?
Lewis:首先,Document object 中记录的 metadata 非常重要。虽然这些 metadata 并不是文档的实际内容,但它包含了丰富的信息,并且在 RAG 系统中发挥着重要作用。一个文档的数据可能来自各种格式,例如 TXT、PDF、HTML 或数据库记录。仅仅依赖原始字符串,无法追踪它的来源,也无法获取相关的附加信息,例如日期、类别等。在许多自然语言处理或信息检索任务中,结合元数据进行过滤、排序或分析,是必不可少的一步。在一些高级索引技术中,metadata 还可以用来存储文本内容摘要、关联的父文档 ID 等。
此外,由于不同数据源都有各自的特征,例如分块策略、段落换行等,因此有必要通过统一的数据结构,例如 Document 对象,进行抽象和标准化。Document 对象可以将多样化的数据源抽象成统一且结构化的形式,方便它们在 LangChain 内部无缝处理,确保文档能够顺利传递给嵌入模型、分类器或问答系统。
下面的代码示例演示了如何直接创建一个 LangChain 的 Document 对象:
from langchain_core.documents import Document
documents = [ Document( page_content="Black clouds lit by fire", metadata={"source": "scene_list.txt"}, ), Document( page_content="Wind rises at dusk", metadata={"source": "scene_list.txt "}, ),]
LangChain 中的数据加载器
LangChain 的数据导入工具并不仅限于 TextLoader。例如,CSVLoader 可以加载 CSV 表格格式的数据,JSONLoader 可以导入 JSON 文件,而 PyPDFLoader 或 PyMuPDFLoader 则可以用来解析 PDF 文件。
值得注意的是,对于同一种文档格式,LangChain 可能会提供好几种不同的加载器。以 PDF 文件为例,LangChain 提供了 10 多种不同的加载器,这有时会让人感到眼花缭乱,也很难判断哪一种最适合当前场景。
图 1.4:一个代码编辑器中显示了一个 Python 字典,将加载器名称映射到文档加载器的模块路径
虽然 LangChain 庞大的生态系统增加了它的复杂性,这也是它常被批评的一点,但这也赋予了 LangChain 强大的文档处理能力,使它能够轻松处理各种数据源,并支持复杂的自然语言处理工作流。
在 LangChain 官方文档中,我们可以找到针对常见文件类型加载器的详细说明。
图 1.5:LangChain 网页显示了一个表格,列出文档加载器及其支持的文件类型
使用 LangChain 读取目录中的所有文件
通常,你可能希望一次性读取某个目录下所有不同类型的文件,并把它们转换成 Document 对象进行统一管理。在 LangChain 中,可以使用 DirectoryLoader,也就是目录文件加载器,来实现这一点。
图 1.6:一个文件夹中包含 txt、md、jpg、csv、pdf 和 settings.txt 等格式的文件
要使用 DirectoryLoader,可以按照以下步骤操作。
首先,使用下面的命令安装 unstructured 包:
pip install unstructured
pip install 'unstructured[image]'
pip install 'unstructured[md]'
sudo apt-get install tesseract-ocr # 这里以 Ubuntu 为例
pip install pytesseract
下面的代码展示了如何使用 DirectoryLoader 从指定目录加载多种类型的文件,并为每个文档生成一个对象。完整代码可参考 github.com/PacktPublis…
from langchain_community.document_loaders import DirectoryLoader
loader = DirectoryLoader('./data/black myth')
docs = loader.load()
print(f'Number of documents: {len(docs)}') # 打印文档总数
输出如下:
Number of documents: 7
可以通过指定 file path 和文件匹配模式,例如通配符,来加载某个目录中特定类型的文件:
loader = DirectoryLoader('./', glob='**/*.md') # 只加载目录中的 Markdown 文件
如果你想在加载过程中看到进度条,可以安装 tqdm 库并启用 show_progress 参数:
loader = DirectoryLoader('./', show_progress=True) # 加载时显示进度条
默认情况下,DirectoryLoader 使用单线程加载文件。为了提高加载速度,可以启用多线程:
loader = DirectoryLoader('./', use_multithreading=True) # 启用多线程进行文档加载
在底层,DirectoryLoader 默认使用 UnstructuredLoader,这是 LangChain 对文档解析工具 unstructured 的集成,用于加载和解析文件。不过,我们也可以通过 loader_cls 参数指定其他加载器。例如,在下面的代码中,我们使用 TextLoader 来加载 TXT 文件,包括 Markdown 以及类似文件:
from langchain_community.document_loaders import DirectoryLoader
from langchain_community.document_loaders import TextLoader
loader = DirectoryLoader(
'data/Black Myth',
glob='**/*.md',
loader_cls=TextLoader # 指定特定加载器
)
docs = loader.load()
print(docs[0].page_content[:100]) # 打印第一个文档内容的前 100 个字符
TextLoader 在处理 Markdown 文件时会保留标题的格式,而 UnstructuredLoader 在处理时不会保留这种格式。下面的图展示了这种差异:
图 1.7:图片对比了 UnstructuredLoader 和 TextLoader 在文本格式处理结果上的差异,并包含 emoji
这里展示的差异主要是为了说明,不同加载器在解析同一种类型的文件时,可能会产生不同结果。这并不意味着 TextLoader 优于 UnstructuredLoader,或者反过来。事实上,每种加载器都有自己的适用场景:TextLoader 更适合加载结构简单的纯文本文件,包括但不限于 Markdown 文件;而 UnstructuredLoader 的适用范围更广,适合更多类型的文件格式,并且能够在处理过程中提取更丰富的结构化信息。
如果你尝试使用 TextLoader 导入所有类型的文件,而不指定文件类型,那么当它遇到不支持的文件类型时,就会抛出错误:
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb5 in position 11: invalid start byte
为了防止程序因为这些错误而中断,可以设置参数 silent_errors=True,允许加载器跳过无法加载的文件,并继续处理剩余文件:
loader = DirectoryLoader(
''data/black myth'',
silent_errors=True, # 跳过无法加载的文件
loader_cls=TextLoader
)
输出如下:
Error loading file data/black myth/black myth wukong.pdf
Error loading file /data/black myth/Black Myth in English.jpg
通过这种配置,当 TextLoader 尝试加载目录中的所有文件时,它会跳过 PDF 或图片等不支持的文件类型,并记录错误信息,而不是抛出异常中断程序执行。
使用 LlamaIndex 读取目录中的所有文档
与 LangChain 类似,LlamaIndex 提供了强大的工具,可以从目录中加载文档,并将这些文档解析成 LlamaIndex 的 Document 对象。在 LlamaIndex 中,这类工具被称为数据连接器或读取器。
一个简单易用的 Reader 是 SimpleDirectoryReader。它可以从指定目录加载各种类型的文件,包括 Markdown、PDF、PPT、Word,以及音频和视频文件。
下面的代码会给出目录中的文档数量:
from llama_index.core import SimpleDirectoryReader
dir_reader = SimpleDirectoryReader('data/black myth')
documents = dir_reader.load_data()
print(f'Number of documents: {len(documents)}')
输出如下:
Number of documents: 11
接下来,我们打印其中一个 Document 对象,看看它的整体结构:
print(documents[1])
输出如下:
Document (id_='d48c275b-c62b-450b-a575-a6ff45ca9a91', embedding=None, metadata={'file_path': '/home/huangjia/Documents/08_RAG/Book2411/rag_240917/data/black myth/ Black Myth Version Introduction.md', 'file_name': 'Black Myth Version Introduction.md', 'file_type': 'text/markdown', 'file_size': 1418, 'creation_date': '2024-11-26', 'last_modified_date': '2024-11-26'}, excluded_embed_metadata_keys=['file_name', 'file_type', 'file_size', 'creation_date', 'last_modified_date', 'last_accessed_date'], excluded_llm_metadata_keys=['file_name', 'file_type', 'file_size', 'creation_date', 'last_modified_date', 'last_accessed_date'], relationships={}, text='\n\nBlack Myth: Wukong \n\n> Black Myth: Wukong is a highly anticipated action-adventure game developed by a Chinese game development team. Based on Journey to the West, it reinterprets the classic story and delivers an impactful visual and gameplay experience.\n', mimetype='text/plain', start_char_idx=None, end_char_idx=None, text_template='{metadata_str}\n\n{content}', metadata_template='{key}: {value}', metadata_seperator='\n')
可以看到,LlamaIndex 在 Document 对象中生成的元数据比 LangChain 更丰富,包括文件路径、文件类型、文件大小、创建日期、修改日期等。这些元数据为文档管理和后续分析提供了更多上下文支持。此外,LlamaIndex 还提供了 excluded_embed_metadata_keys 和 excluded_llm_metadata_keys 选项,可以用来指定哪些元数据不应被纳入信息嵌入或大模型处理。当你需要精简上下文或提高检索效率时,这一点尤其有用。
总体而言,LlamaIndex 在 Document 对象的结构化和细粒度管理方面表现突出,能够满足企业处理多样化、复杂数据的需求。
Alex:我还注意到,使用 LlamaIndex 导入同一个目录时,生成的 Document 对象数量比 LangChain 生成的更多。
Lewis:是的。默认情况下,LangChain 在导入时会为每个原始文件生成一个 Document 对象,不会执行分块。原则上,LlamaIndex 也是这样。不过,对于某些特定文件类型,例如 CSV 文件,LlamaIndex 会自动将其拆分成多个部分,每个部分都被当作一个独立的 Document 对象。这意味着在导入过程中,LlamaIndex 会对 CSV 文件执行分块,因此生成的 Document 对象数量会更多。
你可以使用下面的代码读取目录中的特定文件。完整代码可参考 github.com/PacktPublis…
file_reader = SimpleDirectoryReader(input_files=['data/black myth/The setting of Black Myth Wukong.txt'])
documents = file_reader.load_data()
下面的代码演示了如何直接生成一个 LlamaIndex 的 Document 对象,并手动添加元数据:
from llama_index.core import Document
documents = [
Document(
text='An underground cave filled with flames and the scent of sulfur, where fire jets continuously erupt from below, illuminating the whole abyss. Wukong must use his jumping ability and golden staff to make his way through the lava.',
metadata={
'filename': 'Blazing Abyss.md',
'category': 'Game Scene',
'author': 'Ka Ge AI',
'creation_date': '2024-11-20',
},
),
...
]
现在你已经知道如何从特定目录加载各种类型的文件,下一步就是读取文件中的数据。
连接学习者与 LlamaHub,并读取数据库条目
对于 SimpleDirectoryReader 无法处理的文件类型,LlamaIndex 支持通过 LlamaHub 下载并安装更高级的 Reader。
接下来,我们以 MySQL 数据库 Reader 为例进行介绍。使用之前,你应该先安装 Database Reader 连接器。
首先,执行以下命令完成必要安装:
pip install llama-index-readers-database
sudo apt-get install libmysqlclient-dev
sudo apt-get install python3-dev
pip install mysqlclient
然后使用下面的代码从 MySQL 数据库中加载数据:
from llama_index.readers.database import DatabaseReader
reader = DatabaseReader(
scheme='mysql',
host='localhost',
port=3306,
user='username',
password='password',
dbname='example_db'
)
query = 'SELECT * FROM game_scenes' # 选择所有游戏场景
documents = reader.load_data(query=query)
print(f'Number of documents loaded from the database: {len(documents)}')
print(documents)
输出如下:
[Document(id_='43594ec8-2751-496f-b0eb-dbfa183d20a4', embedding=None, metadata={}, excluded_embed_metadata_keys=[], excluded_llm_metadata_keys=[], relationships={}, metadata_template='{key}: {value}', metadata_separator='\n', text_resource=MediaResource(embeddings=None, data=None, text='id: 1, scene_name: Zhu Jia Village, description: The first village where the game starts, full of strong ancient Chinese rural flavor, region: Eastern Plains, environment_type: village, main_enemies: Bandits, monster minions, special_features: Important NPC blacksmith shop, weapons can be upgraded', path=None, url=None, mimetype=None), image_resource=None, audio_resource=None, video_resource=None, text_template='{metadata_str}\n\n{content}'), ... ]
这种方式可以直接把数据库查询结果转换成 Document 对象,同时在 metadata_template 字段中定义 {key}: {value} 的数据结构模式。
Alex,你可以花些时间阅读 LlamaHub 的官方文档:developers.llamaindex.ai/python/fram… 。
使用 Unstructured 工具读取各种类型的文档
如果你不想使用任何框架,而是希望从零开始构建自己的 RAG 系统,那么使用 Unstructured 工具来读取各种类型的文档是一个不错的选择。Unstructured 是一个开源文档处理工具,专门用于支持多种类型文档的解析,并且能够在处理过程中有效保留文档原有的结构信息。
图 1.8:Unstructured 工具
与 LangChain 和 LlamaIndex 类似,使用 Unstructured 工具导入文档后,它会生成一种独特的数据对象,称为 Element。
首先,使用 partition_text 函数来查看读取文本文件的过程:
from unstructured.partition.text import partition_text
text = 'data/black myth/The setting of Black Myth Wukong.txt'
elements = partition_text(text)
for element in elements:
print(element)
事实上,partition_text 函数是 LangChain 目录加载器底层逻辑的一部分。
接下来,我们看看生成的 elements 数据对象中包含哪些细节:
for i, element in enumerate(elements):
print(f"\n--- Element {i+1} ---")
print(f"Element type: {element.__class__.__name__}")
print(f"Text content: {element.text}")
if hasattr(element, 'metadata'):
print('Metadata:')
metadata = vars(element.metadata)
valid_metadata = {k: v for k, v in metadata.items()
if not k.startswith('_') and v is not None}
for key, value in valid_metadata.items():
print(f" {key}: {value}")
输出内容示例如下:
The story of “Black Myth: Wukong” can be divided into 6 sections, named ‘‘Fire Illuminates Black Clouds’’, ‘‘Wind Rises at Dusk’’, ‘‘Night Gives Birth to White Dew’’, ‘‘Twisted Purple Mandarins’’, ‘‘Sunset in the Mortal World’’, and ‘‘Unfinished’’, and has two possible endings. The learner’s choices and experiences will influence the ultimate ending. At the end of each section, there are 2D and 3D animated cutscenes that present and explore the narrative and thematic elements of “Black Myth: Wukong”.
通过这种方式,Unstructured 工具不仅导入了文档,还会按照特定规则对文档进行分块处理。
通过使用 partition 函数,你可以自动读取任意类型的文件:
from unstructured.partition.auto import partition
filename = 'data/black myth/black myth wukong.pdf'
elements = partition(
filename=filename,
content_type='application/pdf'
)
print('\n\n'.join([str(el) for el in elements][:10]))
虽然 partition 函数是通用的,适用于多种文件类型,但在处理特定文件时,它的功能相对简单。相比之下,像 partition_html 和 partition_pdf 这样的专用函数,在处理各自对应的文档类型时,能够展现出更鲜明的功能和优势。
虽然 Unstructured 工具很强大,但它并不是唯一选择。在实际项目中,我们经常需要根据具体需求选择不同工具。例如,要处理 PDF 文件,PyMuPDF 也是一个常见选择。当然,还有很多其他选择。使用 PyMuPDF 之前,请先通过以下命令安装它:
pip install pymupdf
下面的代码示例演示了如何执行相关操作:
import pymupdf
doc = pymupdf.open(''data/black myth/black myth wukong.pdf'')
text = [page.get_text() for page in doc]
你可以比较不同工具解析出的文本格式差异。
至此,我们完成了对简单文本读取的介绍。虽然这些小节介绍的内容比较基础,但它覆盖了多方面的技术点。关键在于,每个工具都会生成自己的结构化数据对象,而这些对象都包含一组元数据。希望你能够亲自动手实践,以加深对这些工具的理解。
使用 JSON 加载器解析特定元素
为特定文件类型选择合适的加载器,可以提升数据处理效率。在本节中,我们将探讨 LangChain 中 JSON 加载器的使用方式和特点。
首先,我们来看一个包含丰富数据结果信息的 JSON 文件:
图 1.9:一个关于西游题材游戏的 JSON 文件,其中展示了主角和辅助角色的详细信息
如果我们使用 TextLoader 加载 JSON 文件,输入和输出如下:
from langchain_community.document_loaders import TextLoader
text_loader = TextLoader("data/Journey_to_the_West_Characters.json")
text_documents = text_loader.load()
print(text_documents)
输出如下:
[Document(metadata={'source': 'data/Journey_to_the_West_Characters.json'}, page_content='{
"gameTitle": "Journey to the West",
"basicInfo": {
"engine": "Unreal Engine 5",
"releaseDate": "2024-08-20",
"genre": "Action Role-Playing",
"platforms": ["PC", "PS5", "Xbox Series X/S"],
"supportedLanguages": ["Simplified Chinese", "Traditional Chinese"]
},
"mainCharacter": {
"name": "Sun Wukong",
"backstory": "At the dawn of chaos... Sun Wukong.",
"abilities": ["Seventy-Two Transformations", "Golden Hoop Staff", "Cloud Somersault", "Fire Eyes Golden Gaze"],
"supportCharacters": [
{
"name": "White Dragon Horse",
"identity": "One of the Eight Heavenly Dragons",
"background": "Originally the Third Prince of the West Sea Dragon King...",
"abilities": ["Water Escape", "Riding Clouds and Mist", "Transformation"]
},
{
"name": "Red Boy",
"identity": "Holy Infant King",
"background": "Son of Bull Demon King and Princess Iron Fan...",
"abilities": ["Samadhi True Fire", "Fire Eyes", "Combat Form"]
},
{
"name": "Six-Eared Macaque",
"identity": "Clone of Sun Wukong",
"background": "A mysterious figure matching the Monkey King's abilities.",
"abilities": ["Imitation", "Stealth", "Speed"]
}
]
}
}')]
从上面的代码可以看到,TextLoader 会把 JSON 文件当作纯文本读取。这意味着整个 JSON 内容都会作为字符串存储在 page_content 字段中。如果你想使用 page_content 中的特定字段值,仍然需要进一步解析这个字符串。
如果使用 JSONLoader,则可以通过 jq 查询语法直接从 JSON 文件中提取特定元素。
首先,我们需要安装必要的 jq 库。jq 是一个轻量级 JSON 处理工具,适合解析、操作和格式化 JSON 数据:
pip install jq
下面的代码示例演示了如何使用 JSONLoader 解析 JSON 文件。完整代码可参考 github.com/PacktPublis…
from langchain_community.document_loaders import JSONLoader
## 提取并打印主角信息
print("Main character information:")
main_loader = JSONLoader(
file_path="data/black myth/black mythpersona.json",
jq_schema='.mainCharacter | "Name:" + .name + ",Backstory:" + .backstory',
text_content=True
)
main_char = main_loader.load()
print(main_char)
## 提取并打印辅助角色信息
print("\nSupporting character information:")
support_loader = JSONLoader(
file_path="data/black myth/black mythpersona.json",
jq_schema='.supportCharacters[] | "Name:" + .name + ",Background:" + .background',
text_content=True
)
support_chars = support_loader.load()
print(support_chars)
现在让我们看一个将角色拆分为文档对象的示例:
Main character information:
[Document(metadata={'source': '/journey_to_the_west_persona.json', 'seq_num': 1}, page_content='Name: Sun Wukong......')]
Supporting character information:
[Document(metadata={'source': ' journey_to_the_west_persona.json', 'seq_num': 1}, page_content='Name: White Dragon Horse......')Document(metadata={'source': ' journey_to_the_west_persona.json', 'seq_num': 2}, page_content='Name: Red Boy......', ......)]
可以看到,JSONLoader 可以将每个角色拆分成多个 Document 对象,并使用 seq_num 为它们编号。每个 Document 对象不仅包含原始文档的元数据,例如源文件名,还会解析文档内部的数据结构,也就是具体字段信息。
从图像中读取文本
Lewis:在许多真实世界的 AI 应用中,有价值的信息不仅嵌入在纯文本文件中,也嵌入在图像、扫描文档、演示文稿和 PDF 中。让我们探讨一下,在实际 AI 工作流中,如何使用现代技术读取、解析和处理基于图像的信息。
使用 UnstructuredLoader 读取文本
Alex:Lewis,Unstructured 工具可以读取各种格式的文件。你前面还提到,LangChain 的目录加载器默认使用 UnstructuredLoader 来加载文档。你能详细解释一下这个工具吗?
Lewis:Unstructured 是 Unstructured.IO 提供的文本提取工具包。它可以在本地运行,也可以通过 Unstructured API 使用,并且支持解析多种类型的文档。
如果你希望以最小安装方式运行 Unstructured 工具,可以执行以下命令,并根据不同文档类型按需安装依赖:
pip install unstructured
如果你想调用 Unstructured API,需要执行以下命令:
pip install unstructured-client
然后你还需要申请并配置相应的 API Key。
如果你想在 LangChain 中使用这个工具,可以运行以下命令安装相关包:
pip install langchain-unstructured
除了通用的 UnstructuredLoader 之外,LangChain 还集成了各种面向特定文件格式的 Unstructured 文档加载器,例如 UnstructuredExcelLoader、UnstructuredMarkdownLoader 和 UnstructuredImageLoader。完整加载器列表可以访问 LangChain 官方网站查看更多信息。
使用 UnstructuredImageLoader 读取文本
在本节中,我们将选择使用 UnstructuredImageLoader,尝试读取一张包含英文文本的图片。
图 1.10:一只身着华丽盔甲、手持长棍的猴子战士站立着,上方有 Black Myth Wukong 文本
让我们加载这张图片并读取像素信息:
from langchain_community.document_loaders import UnstructuredImageLoader
image_path = "data/black myth/Black Myth in English.jpg"
loader = UnstructuredImageLoader(image_path)
data = loader.load()
print(data)
这段代码会得到如下输出:
yolox_l0.05.onnx: 100%|██████| 217M/217M [00:01<00:00, 116MB/s]
[Document(metadata={'source': 'data/black myth/Black Myth in English.jpg'}, page_content=',\n\nPons\n\n= ens eens WUKONGY\n\n4')]
从前面的输出可以看到,这一过程调用了深度学习模型,例如 YOLO,来分析图像像素信息,从而识别并提取文本内容。这是 OCR 技术的一种工作方式。此外,你也可以通过设置参数来指定其他 OCR 方法,例如 Tesseract。提取出的文本同样被封装在一个 Document 对象中。
不过,在这个例子中,“Black Myth WUKONG” 被错误识别成了 “ens eens WUKONGY”,这说明 OCR 结果并不理想。在这种情况下,可能是图片本身的特征导致准确识别变得困难。
从 PPT 中读取文本
Alex:这只是文本提取。那如果要分析图片内容呢?
Lewis:Unstructured 工具专长于从文件中提取和解析文本内容,而不是分析或处理图像本身。这意味着它无法直接读取图像内容,也不能告诉我们图片里有一只威风凛凛的猴子。如果你想理解图片的具体内容,或者理解 PPT、PDF 等文件中的图像内容,就需要调用大模型 API,或者使用能够分析图像的本地多模态模型,例如 BLIP。
Alex:LangChain 的加载器集成了 Unstructured 这样的外部工具能力,并生成 Document 对象。如果我们直接使用这些外部工具来做文件解析,也完全可以,对吧?
Lewis:没错。
下面的代码示例演示了如何直接使用 Unstructured 工具的 partition_ppt 函数从 PPT 中读取文本。完整代码可参考 github.com/PacktPublis…
from unstructured.partition.ppt import partition_ppt
ppt_elements = partition_ppt(filename="data/black myth wukongPPT.pptx")
for element in ppt_elements:
print(element.text)
输出如下:
Facing Destiny
Prologue
"Black Myth: Wukong" is a Chinese mythological action RPG adapted from "Journey to the West". Players take on the role of the "Chosen One" and pursue the secrets behind the legend during a perilous Westward adventure.
Adapted from the Chinese fantasy novel "Journey to the West"......
获得解析后的原始文本之后,下一步是手动将 pdf_elements 的内容转换成 LangChain 的 Document 对象。
from langchain_core.documents import Document
documents = [ Document(page_content=element.text, metadata={"source": "data/black myth wukongPPT.pptx"}) for element in ppt_elements]
print(documents[0:3])
这相当于手动实现了 LangChain 所需要的 UnstructuredPPTLoader。
使用大模型进行整体图文解析
在问答系统中,我们希望能够直接将 PDF 或 PPT 文件上传到知识库,并基于其中的图像内容回答问题。要实现图像的整体解析,某些工具,例如 Unstructured,目前还无法完成。不过,现代多模态大模型可以轻松完成这项任务。
例如,当我们上传一个同时包含文字和图片的 PDF 文件时,模型会先解析图片内容,并生成类似“一个威风凛凛的猴子站在山顶,周围云雾缭绕”的描述,然后再结合文本信息构建上下文知识库。这种方式让文本和图像的整合更加直观和完整,也使跨模态推理成为可能。也就是说,它可以同时利用图像中的隐含语义和文本中的信息,给出更复杂的回答。例如,它可以回答这样的问题:猴子腾云驾雾的能力可能与什么样的背景环境有关?
图 1.11:一个 PDF 页面,展示了中国奇幻游戏场景,其中有一名战士角色和文字叠加
要从 PDF 文件中同时读取图片和文本,实现步骤可以从调用大模型为每一页生成 caption 开始,然后将这些 caption 转换成 LangChain 所需的 Document 对象。运行此程序需要在环境变量中设置 OpenAI API Key。我们来看一下实现代码。
使用 pdf2image 将 PDF 文件的每一页提取为图片:
from pdf2image import convert_from_path
import base64
import os
output_dir = "temp_images"
if not os.path.exists(output_dir):
os.makedirs(output_dir)
## 将 PDF 文件转换为图片并保存到指定目录
images = convert_from_path("data/black myth/black myth wukong.pdf")
image_paths = []
for i, image in enumerate(images):
image_path = os.path.join(output_dir, f'page_{i+1}.jpg')
image.save(image_path, 'JPEG')
image_paths.append(image_path)
print(f"Successfully converted {len(image_paths)} pages")
接下来,使用多模态大模型分析提取出的图片并生成描述文本:
from openai import OpenAI
client = OpenAI()
print("\nStarting image analysis......")
results = []
for image_path in image_paths:
with open(image_path, "rb") as image_file:
base64_image = base64.b64encode(image_file.read()).decode('utf-8')
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "Please describe this slide in detail, including its title, main text, and image content."},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}"
}
}
]
}
],
max_tokens=300
)
results.append(response.choices[0].message.content)
最后,将生成的描述文本及相关元数据转换成 LangChain 的 Document 对象:
from langchain_core.documents import Document
documents = [
Document(
page_content=result,
metadata={'source': 'data/BlackMyth/BlackMythWukong.pdf', 'page_number': i+1}
)
for i, result in enumerate(results)
]
print('\nAnalysis results:')
for doc in documents:
print(f'Content: {doc.page_content}\nMetadata: {doc.metadata}\n')
输出示例如下:
Wukong title
The main title is “Wukong,” accompanied by a red seal-style graphic.
Body
The central text is “BLACK MYTH WUKONG,” indicating this slide may be related to a game or project called “Black Myth: Wukong.”
The date below shows “08.20,” possibly hinting at an important release or event date.
There is also a phrase “Face Your Destiny,” which may convey the game’s theme or challenge.
Image content
The background of the slide features a mysterious black patterned design.
In the center is a close-up of a character’s face, featuring dense fur and intense eyes, giving a strong sense of power and aura.
The character’s expression is stern, highlighting its characteristics and creating a striking atmosphere.
Overall, this slide appears to showcase game information related to “Wukong,” delivering a fusion of ancient mythology and modern gaming.
Prologue title
Section (“Prologue”)
Body
The body describes a game called Black Myth: Wukong, a Chinese mythology action role-playing game adapted from Journey to the West. It highlights the player’s role as an adventurer in the game environment and explores background stories related to the Destined One.
Image content
The image shows a circle of blazing halo, seemingly glowing on a rock, creating a mysterious and fantastical atmosphere. The background is dark-toned, adorned with intricate patterns, adding visual appeal to the image.
Overall, the slide aims to introduce the game’s theme and background, using visual elements to enhance the feeling of exploring mythological stories.
Adaptations from Journey to the West
Adapted from the Chinese mythological novel “Journey to the West.”
Body
The slide contains no other text except the title.
Image content
The background is dark-toned, seemingly with a blurred human figure, possibly relevant to the plot or characters of Journey to the West, depicting a mysterious and classical atmosphere.
Overall, the design style of this slide likely aims to create a sense of history and mystery, focusing on the classic work Journey to the West .
通过上述步骤,我们已经成功将 PDF 文件中每一页的图像及其包含的文本内容转换成结构化文本描述。接下来,通过结合 LangChain 和 LlamaIndex 等框架,我们可以将这些文本信息以及相应的图像描述转换成 Document 对象。将这些 Document 对象保存到知识库之后,它们就可以成为 RAG 系统的一部分,使后续流程中的问答引擎能够利用这些文本信息回答用户查询。
导入 CSV 格式的表格数据
在处理和解析数据时,导入 CSV 文件是一项常见需求。LangChain 提供的 CSVLoader 工具可以满足这一需求。
使用 CSVLoader 导入数据
加载 CSV 文件时,CSVLoader 会自动为每一行数据生成 page_content 和 metadata。其中,metadata 包含数据来源,也就是 source,以及行号,也就是 row。这对后续数据处理和查询非常有用。
图 1.12:一个电子表格,显示了类别名称、描述和数值形式的能力等级
下面的代码示例演示了一个简单的操作流程。完整代码可参考 github.com/PacktPublis…
from langchain.document_loaders import CSVLoader
file_path = 'data/BlackMyth/BlackMythWukong.csv'
loader = CSVLoader(file_path=file_path)
data = loader.load()
for record in data[:2]:
print(record)
输出如下:
page_content='Category: Equipment Name: Bronze Cloud Staff Description: A sturdy bronze staff that emits a sharp sound when swung, suitable for melee attacks. PowerLevel: 85'
metadata={'source': 'data/BlackMyth/BlackMythWukong.csv', 'row': 0}
page_content='Category: Equipment Name: Hundred Tricks Undergarment Armor Description: A finely crafted battle armor that provides strong defense and resists potent poison damage. PowerLevel: 90'
metadata={'source': 'data/BlackMyth/BlackMythWukong.csv', 'row': 1}
在这个例子中,page_content 包含每一行的详细内容,而 metadata 提供数据来源的文件路径和行号,这有助于后续数据查询和处理。需要注意的是,CSV 文件的第一行被视为表头行,其内容默认会被用作字段名,也就是列名。
在下面的代码示例中,我们通过 csv_args 指定 CSV 文件的一些参数,并使用自定义列名:
loader = CSVLoader(
file_path=file_path,
csv_args={
'delimiter': ',',
'quotechar': ''',
'fieldnames': ['Category', 'Name', 'Description', 'PowerLevel'],
},
)
data = loader.load()
for record in data[:2]:
print(record)
输出如下:
page_content='Category: Category Name: Name Description: Description PowerLevel: PowerLevel'
metadata={'source': 'data/black myth/black myth wukong.csv', 'row': 0}
page_content='Category: Equipment Name: Bronze Cloud Staff Description: A sturdy bronze staff that can produce a swooshing sound when swung, suitable for melee attacks. PowerLevel: 85'
metadata={'source': 'data/black myth/black myth wukong.csv', 'row': 1}
经过这种处理后,Category、Name 等新列名会替换 page_content 中原来的字段名。由于额外指定了字段名,这种导入方式会把文件中的第一行直接当作数据行,而不是表头行。
你可以使用 CSV 文件中的某一列来设置元数据 source 的值。该列的内容会替换默认的 CSV 文件名,成为每条文档记录的来源标识。下面的代码示例展示了这一点:
loader = CSVLoader(file_path=file_path, source_column='Name')
data = loader.load()
for record in data[:2]:
print(record)
输出如下:
page_content='Category: Equipment Name: Bronze Cloud Rod Description: A sturdy bronze rod that produces a whistling sound when swung, suitable for close combat attacks. PowerLevel: 85'
metadata={'source': 'Bronze Cloud Rod', 'row': 0}
page_content='Category: Equipment Name: Hundred Tricks Coin Armor Description: A finely crafted combat armor that provides strong defense and resists powerful poison damage. PowerLevel: 90'
metadata={'source': 'Hundred Tricks Coin Armor', 'row': 1}
在这个例子中,source_column 参数指定 Name 列作为数据来源。因此,metadata 中的 source 字段会取每一行对应 Name 列的值。例如,对于第一条记录,source 值是 “Bronze Cloud Rod”;对于第二条记录,source 值是 “Hundred Tricks Coin Armor”。
这种新生成的元数据信息,在查询特定项目时非常有用。例如,在一个问答链中,如果你只想查询与 Hundred Tricks Coin Armor 相关的记录,就可以通过 source 字段进行过滤。
比较 CSVLoader 和 UnstructuredCSVLoader
Alex:Lewis,你前面提到 Unstructured 工具几乎可以加载所有类型的文件。我们能不能比较一下 CSVLoader 和 UnstructuredCSVLoader 的结果?
Lewis:当然。下面的代码示例展示了如何使用 UnstructuredCSVLoader 从指定 path 加载数据并打印出来:
from langchain_community.document_loaders import UnstructuredCSVLoader
loader = UnstructuredCSVLoader(file_path=file_path)
data = loader.load()
print(data)
输出如下:
[Document(metadata={'source': 'data/black myth/black myth wukong.csv'}, page_content='\n\nCategory\nName\nDescription\nPowerLevel\n\nEquipment\nBronze Cloud Staff\nA sturdy bronze staff that makes a whistling sound when swung, suitable for close combat.\n85\n\nEquipment\nHundred Show Lining Armor\nAn exquisite battle armor that provides strong defense and resists poisonous damage.\n90\n\nSkill\nHeavenly Thunder Strike\nSummon heavenly thunder to attack enemies, causing a wide range of lightning damage.\n95\n\nSkill\nFlame Dance\nPerform a fiery dance, surrounding enemies in searing flames.\n92\n\nCharacter\nWukong\nThe protagonist, possesses the abilities of seventy-two transformations and riding clouds and mist, upholding justice.\n100\n\nCharacter\nSilver Horn King\nOne of the powerful demon kings, skilled at wielding various magical artifacts, with extremely high combat power.\n88\n\n')]
Alex:对于 CSV 文件来说,LangChain 的 CSVLoader 比 UnstructuredCSVLoader 更实用,因为它更好地保留了文档结构。每一行都会被处理成一个独立的 Document 对象,并且元数据保留了重要的 row id 字段,这个字段可以在检索过程中作为“数据源索引”。当然,如果你的任务需要把整个 CSV 文件当成一个单一文本块来处理,那就是另一回事了。
Lewis:是的,在 RAG 系统的数据摄取中,尽可能保留原始文档的结构信息,是一个永恒目标,也是一项挑战。例如,CSV 文件中的行号、Markdown 文件中的标题和层级,以及同时包含文本和图像的 PDF 页面中图像所在的位置——这些都是数据摄取过程中需要考虑的因素。
Alex:那么 Lewis,如果我使用 DirectoryLoader 一次性加载多种类型的文档,并且我希望像 PDF 这样的文件使用默认加载器,但 CSV 文件使用 CSVLoader,应该怎么做?
Lewis:这也很简单。你可以参考下面的代码。完整代码可参考 github.com/PacktPublis…
loader = DirectoryLoader(
path= 'data/Black Myth',
glob='**/*.csv', # 匹配所有 CSV 文件的模式
loader_cls=CSVLoader # 指定对匹配文件使用 CSVLoader
)
完成这样的设置之后,DirectoryLoader 会使用 CSVLoader 加载指定目录下所有 CSV 文件,而不是使用默认的 UnstructuredCSVLoader。
爬取并解析网页文档
在本节中,我们将探索如何爬取网页并将其转换成 LangChain 的 Document 对象。网页内容不仅包含文本信息,还包含图像和其他多媒体元素,这些通常被编码在 HTML 格式中,并且可能包含指向其他页面或资源的链接。
LangChain 提供了多种网页文档加载器,以适应不同应用场景。这些加载器如表 1.1 所示:
| 文档加载器 | 描述 | 包 / API | 特点 |
|---|---|---|---|
| WebBaseLoader | 使用 urllib 和 BeautifulSoup 加载并解析网页 | Package | 操作简单,适合基础网页内容爬取 |
| UnstructuredLoader | 使用 Unstructured 工具加载并解析网页 | Package | 支持复杂网页结构,适合处理异构内容 |
| RecursiveURLLoader | 从根 URL 开始递归爬取所有子链接 | Package | 自动化链接爬取过程,适合大规模网站数据采集 |
| SitemapLoader | 基于提供的 sitemap 爬取所有网页 | Package | 高效解析网站结构,快速获取所有网页内容 |
| Firecrawl | 提供本地部署的 API 服务;托管版本提供免费额度 | API | 灵活且可扩展,适合需要实时爬取和转换的应用 |
表 1.1:各种文档加载器及其说明
在以下小节中,我们将重点介绍 WebBaseLoader 和 UnstructuredLoader 的具体实现。
使用 WebBaseLoader 解析网页
你可以使用 WebBaseLoader 快速加载网页文件,并为每个页面生成一个包含“扁平化”字符串内容的 Document 对象。
首先,需要安装 beautifulsoup4 库:
pip install beautifulsoup4
下面的代码示例演示了如何将 Black Myth: Wukong 的维基百科页面加载为一个 Document 对象:
import bs4
from langchain_community.document_loaders import WebBaseLoader
page_url = 'https://zh.wikipedia.org/wiki/black myth,Wukong'
loader = WebBaseLoader(web_paths=[page_url])
docs = loader.load()
print(f"{docs[0].metadata}\n")
print(docs[0].page_content.strip())
前面的代码会提取文本并生成如下输出:
{'source': 'https://zh.wikipedia.org/wiki/black myth,Wukong', 'title': 'black myth,Wukong - Wikipedia, the free encyclopedia', 'language': 'zh'}Black Myth: Wukong - Wikipedia, the free encyclopedia Skip to content Main menu Main menu Move to sidebar Hide navigation Home Category Index Featured Content News......Table of Contents Move to sidebar Hide Preface 1 Gameplay 2 Plot 2.1 Setting 2.2 Story 2.2.1 Prologue 2.2.2 Seeking Root......
前面的方法会提取页面的完整文本,但可能包含不必要的信息,例如标题或导航栏。如果你熟悉网页的 HTML 结构,可以使用 BeautifulSoup 指定所需的 <div> 类名,从而过滤掉不需要的内容。
下面的代码示例演示了如何只解析并提取网页正文内容:
loader = WebBaseLoader(
web_paths=[page_url],
bs_kwargs={ 'parse_only': bs4.SoupStrainer(id='bodyContent'), }, # 只解析网页内容的主体部分
bs_get_text_kwargs={'separator': ' | ', 'strip': True},
)
下面是输出中提取出的网页内容形式:
{'source': 'https://zh.wikipedia.org/wiki/black myth,Wukong'}
Wikipedia, The Free Encyclopedia | Black Myth: Wukong | Genre | Role-playing | | Platform | Microsoft Windows | PlayStation 5 | Xbox Series X/S | Developer | Game Science | ...
这里,parse_only: bs4.SoupStrainer(id="bodyContent") 指的是网页中 id="bodyContent" 的 HTML 元素。它通常代表网页的主要内容区域,主要包含文章或页面的核心信息,不包括导航栏、页脚或其他辅助元素。
这样会得到更干净的结果,过滤掉 Jump to content、Main menu、Move to sidebar、Hide navigation、Home Page、Category、Index、Featured Content 等无意义的链接文本,直接聚焦知识主体。
使用 UnstructuredLoader 进行细粒度网页解析
如果需要对内容进行更细粒度的控制,可以选择更高级的解析方式,例如使用 Unstructured Loader 进行解析。这种方式适合需要对特定网页内容进行精确索引的场景。处理后,每个网页会生成多个 Document 对象,每个对象表示页面上的不同结构,例如标题、正文、列表或表格。
首先,确保已安装 langchain-unstructured 接口包。这里我们通过本地调用方式使用 Unstructured 包,后续内容也会展示如何通过 API 调用 Unstructured 工具:
pip install 'langchain-unstructured[local]'
下面的代码示例演示了如何使用 Unstructured 工具加载同一个网页:
from langchain_unstructured import UnstructuredLoader
page_url = 'https://zh.wikipedia.org/wiki/black myth,Wukong'
loader = UnstructuredLoader(web_url=page_url)
docs = loader.load()
for doc in docs[:5]:
print(f'{doc.metadata['category']}: {doc.page_content}')
输出如下:
Title: Black Myth: Wukong
ListItem:

ListItem:

ListItem: Azerbaijani
ListItem: Belarusian (Taraškievica)
...
这里输出的每个 Document 对象都代表页面中的一个元素。metadata 中包含该元素的类别,例如标题或正文。
Lewis 的提示
在本节中,我们了解到 Unstructured 工具能够分析文件中的各种非结构化元素,并将其解析为 Element 数据对象。
借助 LangChain 的 Unstructured-Loader,我们可以进一步将这些 Element 数据对象转换成 Document 对象。
解析后的页面元素可能具有父子关系。例如,一个段落可能属于某个特定标题或表格,其中 category 为 Title 或 Table。你可以使用下面的代码提取并组合这些页面元素:
from langchain_unstructured import UnstructuredLoader
from typing import List
from langchain_core.documents import Document
page_url = 'https://zh.wikipedia.org/wiki/black myth,Wukong'
def _get_setup_docs_from_url(url: str) -> List[Document]:
loader = UnstructuredLoader(web_url=url)
setup_docs = []
for doc in loader.load():
if doc.metadata['category'] == 'Title' or doc.metadata['category'] == 'Table':
parent_id = doc.metadata['element_id']
current_parent = doc # 更新当前父元素
setup_docs.append(doc)
elif doc.metadata.get('parent_id') == parent_id:
setup_docs.append((current_parent, doc)) # 将父元素和子元素一起保存
return setup_docs
docs = _get_setup_docs_from_url(page_url)
for item in docs:
if isinstance(item, tuple):
parent, child = item
print(f'Parent element - {parent.metadata['category']}: {parent.page_content}')
print(f'Child element - {child.metadata['category']}: {child.page_content}')
else:
print(f'{item.metadata['category']}: {item.page_content}')
在前面的代码中,current_parent 变量用来存储当前父元素。当遇到子元素时,它会和当前父元素一起作为元组保存。输出时,会检查该元素是否为元组;如果是,就分别打印父元素和子元素。这样可以确保每个子元素及其对应的父元素都能被清晰展示。
理解《黑神话:悟空》的文化与故事背景
《黑神话:悟空》是由游戏科学开发并发行的一款动作角色扮演游戏,被媒体誉为中国第一款 AAA 游戏。
游戏内容改编自中国四大名著之一《西游记》。在正式发布之前,该游戏就已经获得行业媒体和评论家的广泛赞誉,他们称赞其战斗系统、视觉设计和世界构建。上线之后,游戏迅速登顶多个平台的销量榜,并在发布一个月内全球销量超过 2000 万份,成为有史以来销售速度最快的游戏之一。
Lewis 的提示
在 Unstructured 工具解析出的 Element 数据对象中,parent_id 是一个重要的元数据字段。它表示当前元素的父元素。该字段有助于构建和维护文档的层级结构,清晰定义当前元素与上层结构之间的关系,这在解析复杂文档时尤其重要。
例如,一个表格项可能包含一个指向其所属标题或章节的 parent_id。这使得在重建文档树结构时,可以准确还原每个内容块的位置和层级关系。
在展示或分析文档结构时,可以根据 parent_id 对内容进行重组。例如,可以将表格项正确归入其对应标题下面。
Markdown 文件的标题和结构
Alex,到这里你可能已经注意到,我们一直在强调加载文档后保留原始信息的重要性。确实,这些文档自身固有的格式,例如 CSV 文件中的行 ID,或者 HTML 文件中的元素层级,都包含结构性或关系性信息,而这些信息可能在 RAG 系统的索引、检索和生成过程中发挥重要作用。
为什么是 Markdown?
我们接下来要讨论的 Markdown 文档,是构建 RAG 系统时极其重要的一类文件。将源数据统一为 Markdown 格式的做法背后,有以下几个原因。
Markdown 是一种轻量级标记语言,易于阅读和解析。与 HTML 或 XML 等更复杂的标记语言相比,Markdown 语法更加简单清晰,因此无论是人工解析还是自动解析都更容易。这有利于文档预处理、拆分和总结,以及后续的特征提取和索引构建。
它的风格接近大模型使用的训练数据:大多数大模型,例如 ChatGPT 和 DeepSeek,在训练过程中已经接触过大量 Markdown 格式文本,包括 GitHub README、技术文档、博客文章等。这意味着面对 Markdown 内容时,这些大模型可以更有效地提取有用信息,并生成更自然、更恰当的回答。
保留文本的层级结构和基本格式信息:Markdown 可以以相对简单的方式保留标题、段落、列表、表格、代码块等结构信息。这种能力有助于 RAG 系统中的大模型理解文本的逻辑层级和语义分区,从而在回答问题时更好地引用和组织信息。
统一并简化数据格式:由于不同数据源,例如 HTML、PDF、CSV 表格、数据库文本,在格式和结构上差异显著,并且可能包含复杂的 HTML 标签或不同编码方式,因此将所有数据转换为 Markdown,可以在一定程度上实现格式统一,并简化后续处理步骤。
方便后续展示:在最终输出中,RAG 系统可以直接生成 Markdown 格式文本,使前端界面,例如聊天窗口中的答案,同时具备良好的可读性和视觉效果,而不需要额外的格式转换。
因此,Markdown 格式不仅有利于数据预处理以及大模型对数据的理解和解析,也方便清晰呈现信息。值得注意的是,Markdown 文件同样包含层级结构信息。每个标题下方都有对应内容,这些内容不应该被分离。这意味着在解析过程中,确保“标题 - 标题下的正文”这一层级结构非常重要。
图 1.13:截图显示了一个 Markdown 编辑器,其中包含游戏介绍笔记及其预览
实现 UnstructuredMarkdownLoader
接下来,我们来看 UnstructuredMarkdownLoader 的应用细节。在默认模式下,UnstructuredMarkdownLoader 会将整个 Markdown 文件加载为一个单独的 Document 对象。这意味着解析后的内容会一起存储在一个数据列表中,而这个列表只包含一个 Document 对象,其 page_content 属性将包含该文件的全文内容。这种方式尤其适合处理内容较短或不需要进一步细分的文档,因为它便于整体阅读和处理。下面是这个函数的使用方式。完整代码可参考 github.com/PacktPublis…
from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain_core.documents import Document
markdown_path = "data/black myth/Black Myth Version Introduction.md"
loader = UnstructuredMarkdownLoader(markdown_path)
data = loader.load()
print(data[0].page_content)
下面是输出中的应用细节:
Game Version Introduction
Digital Standard Edition
Includes the base game
Digital Deluxe Edition
当启用 mode="elements" 时,UnstructuredMarkdownLoader 会将 Markdown 文件解析成多个元素。如下方代码所示,每个元素都会被视为一个独立的 Document 对象,代表一个独立内容块,例如标题、段落、列表项等。这种方式可以更细粒度地处理课程内容,使其更便于索引和检索。
loader = UnstructuredMarkdownLoader(markdown_path, mode="elements")
data = loader.load()
print(f"Number of documents: {len(data)}\n")
for document in data:
print(f"{document}\n")
下面的输出展示了解析操作后的结果:
Number of documents: 22
page_content='Black Myth: Wukong ' metadata={'source': 'data/black myth/Black Myth Version Introduction.md', 'category_depth': 0, 'languages': ['zho'], 'file_directory': 'data/black myth', 'filename': 'Black Myth Version Introduction.md', 'filetype': 'text/markdown', 'last_modified': '2024-11-26T12:15:59', 'category': 'Title', 'element_id': 'b89add9386b58a1638e0b96d19f08d0d'}
page_content='Black Myth: Wukong is a highly anticipated action-adventure game developed by a Chinese game development team. Inspired by Journey to the West, it reinterprets the classic story, delivering an impactful visual and gaming experience.' metadata={'source': 'data/black myth/Black Myth Version Introduction.md', 'languages': ['zho'], 'file_directory': 'data/black myth', 'filename': 'Black Myth Version Introduction.md', 'filetype': 'text/markdown', 'last_modified': '2024-11-26T12:15:59', 'parent_id': 'b89add9386b58a1638e0b96d19f08d0d', 'category': 'UncategorizedText', 'element_id': '4d1fd58a257960aafb046fc47605c217'}
在解析复杂文档时,metadata 中的 category 字段,例如 Title,有助于理解文档结构,并提供有意义的上下文。例如,一个标题通常表示后续内容的主题或分类。因此,把标题作为一个单独的 Document 对象,有助于在后续检索或分析中定位和组织内容。比如,在 RAG 系统中,可以根据标题过滤特定章节的内容,从而更精确地回答学习者的问题。此外,通过 metadata 中的 parent_id 字段,可以进一步判断哪些元素属于某个特定标题,从而将相关内容组织成一个统一文本块。
PDF 文件中的文本格式、版面识别和表格解析
对于大多数 RAG 系统来说,解析 PDF 文件是构建系统的关键步骤。PDF 文件不仅包含文本信息,也可能包含表格、图像等元素,因此与其他类型文档相比,解析 PDF 更具挑战性。
目前,处理 PDF 文件的常见解析方法大致可以分为三类:基于规则的解析、基于深度学习的解析,以及基于多模态大模型的解析。
使用这些方法的 PDF 解析器可能会执行以下操作:
通过启发式方法或机器学习技术,将分散的文本框重新组合为逻辑单元,例如行或段落。
对文件中的图片应用 OCR 技术,识别并提取其中的文本。
对文本内容进行分类,判断其属于段落、列表、表格还是其他结构。
将提取出的文本组织成表格格式,或者以键值对形式呈现数据。
Lewis 的提示
许多现代大模型现在已经支持多模态输入,可以直接处理图像和 PDF 等多媒体文件。我们前面已经给出了一个示例。
在某些应用场景中,尤其是需要对具有复杂版式、图表或插图的 PDF 文档进行问答和分析时,可以直接将 PDF 文档传给大模型进行理解,而不必先将其转换成更简单的格式。
使用加载工具处理 PDF 文件
LangChain 可以集成多种 PDF 解析器。在这些解析器中,有些设计得简单且相对基础,适合轻量级文本解析场景;而另一些则支持 OCR 功能、数学公式处理和图像分析,或者能够执行高级文档版面分析。
| 解析器 | 描述 | 包 / API | 特点 |
|---|---|---|---|
| PyPDF | 使用 pypdf 加载并解析 PDF 文件 | Package | 高效轻量,适合处理简单 PDF 文件 |
| Unstructured | 使用开源 Unstructured 工具库加载 PDF 文件 | Package / API | 支持多种文档格式,具备内容提取和分析能力 |
| Amazon Textract | 通过 AWS API 加载 PDF 文件 | API | 提供云服务支持,适合大规模文档 OCR 处理 |
| Mathpix | 使用 MathPix 加载并解析 PDF 文件 | API | 专为数学公式设计,能够准确解析复杂内容 |
| PDFPlumber | 使用 PDFPlumber 加载 PDF 文件 | Package | 提供丰富的 PDF 内容控制和处理功能 |
| PyPDFDirectory | 加载目录中的 PDF 文件 | Package | 支持批量加载,便于处理多个 PDF 文件 |
| PyPDFium2 | 使用 PyPDFium2 加载 PDF 文件 | Package | 高效解析,支持 PDF 页面渲染和转换 |
| PyMuPDF | 使用 PyMuPDF 加载 PDF 文件 | Package | 速度优化,支持复杂 PDF 文件的细粒度处理 |
| PDFMiner | 使用 PDFMiner 加载 PDF 文件 | Package | 适合文本提取,尤其擅长处理包含嵌入文本的 PDF |
表 1.2:解析器功能及其特点
这些工具的部署和使用难度,可以从多个角度进行分析,例如以下几个方面。
本地部署类型:PyPDF、PDFPlumber 和 PDFMiner 等工具都是 Python 库,因此安装和使用都相对简单。这类工具通常只需要通过 pip 或其他包管理器安装即可快速上手,适合那些希望避免复杂配置流程的用户。
API 服务类型:Amazon Textract 和 MathPix 属于这一类,需要申请 API Key,而且通常涉及付费使用。虽然这些服务提供了强大功能,例如批量文档处理和数学公式解析,但使用门槛相对较高。
混合类型:Unstructured 作为一个开源库,可以直接用于基础功能,但如果想充分利用其所有特性,可能需要额外的服务支持。
从功能特性角度看,PyPDF 是一个轻量级工具,提供基础 PDF 文本提取能力;PDFPlumber 擅长处理表格数据,并且在版面分析方面能力较强;PyMuPDF 功能全面,支持 PDF 渲染、编辑,以及对复杂文档的细粒度处理;Amazon Textract 具备 OCR 能力,尤其适合扫描文档;MathPix 专为数学公式识别而设计;PDFMiner 拥有非常强大的底层解析能力,能够精确定位文本位置。
从性能角度看,PyPDF 以处理速度快著称,但在准确性方面表现一般;PyMuPDF 在性能和准确性两方面都表现突出;Unstructured 在处理复杂版式方面表现良好,因此被选为 LangChain 的 DirectoryLoader 默认加载器。它的 API 版本也提供高准确率解析服务,不过网络状况可能影响处理速度。
从应用场景角度看,对于简单文档文本解析,PyPDF 已经足够;如果需要处理 PDF 文件中的表格数据,推荐使用 PDFPlumber;面对格式复杂的 PDF 文件时,PyMuPDF 或 Unstructured 是更好的选择;处理扫描文档时,Amazon Textract 是一个理想选择;对于包含大量公式的数学 PDF 文档,推荐使用 MathPix。
总体来说,如果你需要大规模批量处理 PDF 文件,PyMuPDF 因其功能全面且效率高,显得尤其均衡。
PDF 解析是一个很宽泛的话题。接下来,我们可以进一步探索一些基础方法。通过学习并实践每种工具的具体实现细节,掌握它们就不再困难。
使用 PyPDFLoader 进行简单文本提取
如果你只需要从 PDF 文件中提取嵌入文本,并将其表示为简单字符串,可以使用 PyPDFLoader 方法。该方法会返回一个由 Document 对象组成的列表,每一页对应一个 Document 对象。提取出的文本会存储在 Document 对象的 page_content 属性中。
图 1.14:两尊传统塑像并排展示,每尊都描绘了具有独特特征的袍服人物
下面的代码示例演示了如何安装 PyPDF 工具:
pip install pypdf
这种方法不会解析图片或扫描版 PDF 页面,也就是说,它不支持 OCR 功能。如下方代码所示:
from langchain_community.document_loaders import PyPDFLoader
file_path = "data/black myth/ Kang Jinlong and Lou Jingou.pdf"
loader = PyPDFLoader(file_path)
pages = loader.load()
print(f"Loaded {len(pages)} page(s) of PDF document")
for page in pages:
print(page.page_content)
前面的代码会返回标准文本内容:
Loaded 1 page(s) of PDF document
Some characters in the game, such as Kang Jinlong (left) and Lou Jingou (right), draw their inspiration from the painted sculptures at the Jade Emperor Temple in Jincheng, Shanxi. The character Kang Jinlong appears in both human and dragon form, serving as a boss enemy.
在下一节中,我们将看到如何把 PDF 文档转换成 Markdown 格式。
使用 Marker 工具将 PDF 文档转换成 Markdown 格式
在处理包含结构化内容的 PDF 文档时,例如使用层级标题来组织和逻辑化内容的文档,理想做法是在解析 PDF 文档时保留这种层级结构。对于这种需求,将 PDF 文档转换成 Markdown 格式是一个不错的选择。把所有类型的文本标准化为 Markdown 格式,有助于简化后续处理和分析步骤。
图 1.15:关于云冈石窟的维基百科页面,其中有一张岩石中雕刻的大佛照片
在将 PDF 文档转换成 Markdown 格式的过程中,至少应保留以下关键元素:
Markdown 标题层级:在文档中,标题用于组织不同章节的内容。这种层级不仅有助于学习者快速导航,也增强了文档的可理解性、整体可读性和组织性。
图文结构:考虑到许多 PDF 文档包含图表或多栏排版,用来进一步解释文本内容,因此在转换过程中需要解析图片,并将它们保存到专门的图片目录中,然后嵌入 Markdown 文件。同时,还应使用适当格式保留表格数据的呈现方式,确保信息传递的准确性和完整性。
下图展示了一份关于云冈石窟的文档,其中用表格列出了 UNESCO 遗址详细信息:
图 1.16:一份关于云冈石窟的文档截图,其中列出了 UNESCO 遗址详细信息
Lewis:虽然有很多开源或商业工具可以将 PDF 转换为 Markdown,但我个人推荐一个用户体验非常好的工具——Marker。另一个备受好评的工具是 Docking。Marker 可以有效移除页眉、页脚和其他无关内容,支持表格和代码块格式化,并能将大多数公式转换成 LaTeX 格式,这对科学论文尤其有用。此外,它还能准确提取图片。Marker 的核心由一系列深度学习模型构成,专门用于文本提取、OCR、页面版面检测和格式清理。Marker 会根据待解析 PDF 文件的具体格式,智能选择最合适的模型,从而确保解析速度与准确性之间达到最佳平衡。
Alex:Lewis,这就是你在本节开头提到的第二种 PDF 解析方法——基于深度学习的解析。
下面的代码示例演示了如何使用 Marker 解析 PDF 文档。
首先,使用下面的命令安装 Marker:
pip install marker-pdf
接下来,可以直接使用命令行解析 PDF 文件:
marker_single ''data/Shanxi Cultural Tourism/Yungang Grottoes-en.pdf''
此外,也可以使用下面的代码示例解析 PDF 文件。完整代码可参考 github.com/PacktPublis…
import os # 导入 os 库
import subprocess # 导入 subprocess 库
def convert_pdf_to_markdown(input_pdf_path, output_folder, batch_multiplier=2, max_pages=12):
if not os.path.exists(output_folder):
os.makedirs(output_folder)
command = [
'marker_single',
input_pdf_path,
output_folder,
f'--batch_multiplier={batch_multiplier}',
f'--max_pages={max_pages}'
]
try:
subprocess.run(command, check=True)
print(f"PDF document successfully converted to Markdown format, files saved to {output_folder}")
except subprocess.CalledProcessError as e:
print(f"PDF document conversion failed: {e}")
if __name__ == "__main__":
input_pdf_path = "data/Shanxi Cultural Tourism/Yungang Grottoes-en.pdf"
output_folder = "data/marker/output/Yungang Grottoes-en"
convert_pdf_to_markdown(input_pdf_path, output_folder)
解析操作会返回如下输出:
Loaded detection model **vikp/surya_det3** on device **cuda** with dtype **torch.float16**
Loaded detection model **vikp/surya_layout3** on device **cuda** with dtype **torch.float16**
Loaded reading order model **vikp/surya_order** on device **cuda** with dtype **torch.float16**
Loaded recognition model **vikp/surya_rec2** on device **cuda** with dtype **torch.float16**
Loaded texify model to **cuda** with **torch.float16** dtype
Loaded recognition model **vikp/surya_tablerec** on device **cuda** with dtype **torch.float16**
Detecting bboxes: 100%|█████████████| 1/1 [00:01<00:00, 1.10s/it]
Recognizing Text: 100%|█████████████| 4/4 [03:18<00:00, 49.75s/it]
Detecting bboxes: 100%|█████████████| 1/1 [00:02<00:00, 2.16s/it]
Finding reading order: 100%|██████████| 1/1 [00:00<00:00, 2.05it/s]
Recognizing tables: 100%|████████████| 1/1 [00:07<00:00, 7.89s/it]
Saved markdown to the `data/marker/output/Yungang-Grottoes-en` folder
Total time: 238.9289002418518
PDF document was successfully converted to Markdown format, file has been saved to `data/marker/output`
程序运行后,会生成一个 Markdown 文件,同时生成一系列解析出的 PNG 图片文件,以及一个包含元数据信息的 JSON 文件。
图 1.17:一个文件资源管理器显示了两个文件夹,里面主要包含 PNG 图片、文档和一个 JSON 文件
打开 Yungang Grottoes-en.md 文件,就可以看到预期内容。
图 1.18:截图左侧显示 Markdown 代码,右侧显示云冈石窟内容的格式化预览
Marker 实现了从 PDF 文档到 Markdown 格式的自动转换,并准确保留了原始 PDF 文档中的格式信息。此外,它还提供了灵活的配置选项,包括批处理和页数限制,允许用户根据自身需求调整性能和资源使用。
这种 Markdown 格式文档不仅可以被 LangChain 或 LlamaIndex 等框架读取为 Document 对象,也可以作为基于 RAG 的知识库原始材料。
使用 UnstructuredLoader 进行结构化解析
在上一节中,我们探讨了如何使用 Marker 将 PDF 文档解析成 Markdown 格式。不过,在某些情况下,仅仅依赖这种转换方式可能无法满足所有需求。例如,当需要以更细粒度切分文本时,例如按段落、标题或表格结构切分,或者需要从包含文字的图像中提取文本时,就需要更详细的方法。
我们知道,LangChain 提供的 UnstructuredLoader 会返回一个由 Document 对象组成的列表。每个 Document 对象代表页面上的一个独立结构或元素,并包含丰富的元数据,这极大地方便了后续文档分析和处理。
下图展示了一个 JSON 文件,其中包含云冈石窟坐标和版面信息描述:
图 1.19:一个 JSON 数据截图,描述了云冈石窟的坐标和版面信息
在这样一种数据结构中,不仅保留了基础元数据,例如页码和文本内容,还保留了详细的版面信息,例如元素的坐标和类型。
基础元数据:包括 page_number,也就是页码信息;category,用于区分不同内容类型,例如 “NarrativeText” 表示叙述性文本;content,即文本内容,例如描述云冈石窟的基本信息;element_id,即元素唯一标识符;以及 parent_id,即父元素标识符,有助于理解文档层级结构,并便于对文档内容进行结构化处理。
版面信息,也就是坐标:包括左上角、左下角、右上角和右下角的坐标、坐标系统类型,以及页面宽度和高度信息。借助这些坐标,可以进行精确的版面分析,判断内容的准确位置,从特定区域提取内容,或者重建文档的视觉版面。这支持基于位置的内容过滤、排序,以及版面重建和重排。
能力强大的大模型也许能够基于上述信息,自动实现完美的 PDF 版面还原,从而大幅提升文档处理的效率和准确性。
Alex:Lewis,我不理解这一部分。既然迟早都要还原,为什么我们还要这么费劲地把 PDF 文档拆开?
Lewis:这说明你还没有真正理解 RAG 系统。拆开的目的,是为了让后续向量化过程更加精细;只有把文档切分成独立元素,我们才能在 RAG 系统中根据用户查询准确检索信息。例如,当用户问云冈石窟是哪一年建成的,我们需要快速定位相关文档片段,而不是把整个 PDF 文档都传给大语言模型。那样不仅准确性更低,还会浪费 token 资源。生成过程中的还原,是为了最终向用户呈现一个完整且视觉丰富的答案。
除了之前安装的 langchain-unstructured 接口包之外,由于这里要演示通过 API 调用 Unstructured 工具,我们还需要获取 Unstructured API Key,并设置 UNSTRUCTURED_API_KEY 环境变量。下面的代码示例展示了如何使用 UnstructuredLoader 解析 PDF 文档:
file_path = ("data/Shanxi Cultural Tourism/Yungang Grottoes-en.pdf")
from langchain_unstructured import UnstructuredLoader
loader = UnstructuredLoader(
file_path=file_path,
strategy="hi_res",
partition_via_api=True, # 如果调用本地 Unstructured 工具,请注释掉这一行和下一行
coordinates=True, # 通过 API 调用 Unstructured 工具并返回元素坐标
)
docs = []
for doc in loader.lazy_load():
docs.append(doc)
生成的 doc 文件不仅包含文本内容,还包含结构信息。
Lewis 的提示
strategy="hi_res" 表示“高分辨率模式”。它会尝试捕捉文档中更多细节,尤其是复杂版面、表格、坐标以及类似内容。
使用这种策略处理文档时,它适合包含图像、表格、图形和多栏文本等复杂格式的 PDF 文件。该模式利用更高级的技术,例如 OCR 分析和高分辨率图像解析。
如果改用 fast 模式,也就是设置 strategy="fast",这是默认模式,那么处理速度更快、资源消耗更少。它可以完成基础解析任务,但对复杂版面的支持没有那么细致,更适合格式相对简单的 PDF 文档。
接下来,使用下面的函数提取每一页的文档结构:
def extract_basic_structure(docs):
"""基础结构提取:按照文档类型组织内容"""
# 定义类别映射
category_map = {
'Title': 'title',
'NarrativeText': 'text',
'Image': 'image',
'Table': 'table',
'Footer': 'footer',
'Header': 'header'
}
# 初始化结构字典
structure = {cat: [] for cat in category_map.values()}
structure['metadata'] = [] # 添加元数据类别
# 遍历文档并分类
for doc in docs:
category = doc.metadata.get('category', 'Unknown')
content = {
'content': doc.page_content,
'page': doc.metadata.get('page_number'),
'coordinates': doc.metadata.get('coordinates')
}
target_category = category_map.get(category)
if target_category:
structure[target_category].append(content)
return structure
## 调用函数提取文档结构
structure = extract_basic_structure(docs)
输出第 2 页上 metadata 为 Title 的内容:
print('Title on page 2:')
for title in [t for t in structure['title'] if t['page'] == 2]:
print(f'- {title["content"]}')
如果你观察 PDF 文档的这一页,就能看到标题 “Deterioration and Conservation”。在这个例子中,文档对标题类型元素的解析是准确的。当然,有时 Unstructured 工具也可能误分类,把一些看起来不像标题的元素归类为标题。
下面的代码示例可以用来展示某一页上所有元素的布局:
def analyze_layout(docs):
'''''分析文档的版面结构''''''
layout_analysis = {}
for doc in docs:
page = doc.metadata.get('page_number')
coords = doc.metadata.get('coordinates', {})
# 初始化页面信息
if page not in layout_analysis:
layout_analysis[page] = {
'elements': [],
'dimensions': {
'width': coords.get('layout_width', 0),
'height': coords.get('layout_height', 0)
}
}
# 获取元素位置信息
points = coords.get('points', [])
if points:
# 只需要左上角和右下角坐标点
(x1, y1), (_, _), (x2, y2), _ = points
# 构造元素信息
element = {
'type': doc.metadata.get('category'),
'content': (doc.page_content[:50] + '...') if len(doc.page_content) > 50 else doc.page_content,
'position': {
'x1': x1, 'y1': y1,
'x2': x2, 'y2': y2,
'width': x2 - x1,
'height': y2 - y1
}
}
layout_analysis[page]['elements'].append(element)
return layout_analysis
## 调用函数分析文档版面
layout = analyze_layout(docs)
接下来,输出第 1 页的页面布局内容:
print("Page 1 layout analysis:")
if 1 in layout:
page = layout[1]
print(f"Page size: {page['dimensions']['width']} x {page['dimensions']['height']}")
print("\nElement distribution:")
## 按垂直位置排序并展示元素
for elem in sorted(page['elements'], key=lambda x: x['position']['y1']):
print(f"\nType: {elem['type']}")
print(f"Position: ({elem['position']['x1']:.0f}, {elem['position']['y1']:.0f})")
print(f"Size: {elem['position']['width']:.0f} x {elem['position']['height']:.0f}")
print(f"Content: {elem['content']}")
前面的代码会按照垂直位置对元素进行排序并展示:
Page 1 layout analysis:
Page size: 1700 x 2200
Element distribution:
Type: Header
Position: (827, 41)
Size: 304 x 30
Content: Yungang Grottoes - Wikipedia
Type: Image
Position: (98, 104)
Size: 427 x 142
Content: 4y WIKIPEDIA [ 1 The Free Encyclopedia WIKIPEDIA
Type: Title
Position: (1120, 411)
Size: 326 x 43
Content: Yungang Grottoes......
文档元素可能具有父子关系,例如,一个段落可能属于带有标题的某个章节。你可以通过检查每个元素的类别和内容来判断它是否属于目标章节。例如,如果要提取某个特定标题下的内容,比如第 3 页上关于 “Cave 6” 的介绍,如下图所示,可以使用后面的代码示例。
图 1.20:关于云冈石窟的空气污染研究以及第 6 窟艺术
要提取特定标题下的内容,可以使用下面的代码示例:
cave6_docs = []
parent_id = -1
for doc in docs:
if doc.metadata["category"] == "Title" and "Cave 6" in doc.page_content:
parent_id = doc.metadata["element_id"]
if doc.metadata.get("parent_id") == parent_id:
cave6_docs.append(doc)
for doc in cave6_docs:
print(doc.page_content)
输出中的遗址描述如下:
Cave 6 is one of the richest of the Yungang sites. It was constructed between 465 and 494 C.E. by The entire Emperor Xiao Wen. The cave's surface area is approximately 1,000 square meters. interior of the cave is carved and painted. There is a stupa pillar in the center of the room extending from the floor to the ceiling. The walls are divided into two stories. The walls of the upper stories are host to carvings of standing Buddhas, Bodhisattvas, and monks among other celestial figures. All of the carvings were painted, but because the caves have been repainted evidently up to twelve times, determining the original scheme is difficult.
使用 PyMuPDF 和坐标信息可视化版面
到目前为止,我们已经获得了带有坐标的详细元素信息。接下来,可以使用这些数据进行细粒度版面分析和可视化。
在本节中,我们将结合 PyMuPDF 库以及 UnstructuredLoader 解析出的坐标信息,对 PDF 页面进行可视化,并标注内容区域,例如标题、图片和表格,以便更容易理解 PDF 页面的版面结构,或者处理特定区域的信息。
Lewis 的提示
PyMuPDF 是一个广泛用于 PDF 文档操作的库,支持高效读取、修改和渲染 PDF 文件。PyMuPDF 可以打开并读取 PDF 文档,提取页面中的文本和图像,并访问页面版面细节,例如段落坐标和图片位置。同时,它也支持将 PDF 页面转换为位图格式,并允许执行缩放和旋转等操作。此外,PyMuPDF 支持修改 PDF 文档。你可以向 PDF 添加文本、图片或图形元素,也可以对现有文档进行注释,例如高亮文本或添加评论,从而使 PDF 版面分析结果能够在其他应用中展示。
下面的代码展示了如何使用 PyMuPDF 读取一个 PDF 页面并将其转换成图片,然后使用 matplotlib 绘制该 PDF 页面,并添加矩形框来标记章节区域。不同框颜色会根据区域类别设置,例如 “Title”“Image”“Table”:
import fitz # PyMuPDF 库,用于处理 PDF 文件
import matplotlib.patches as patches # 用于在图片上绘制多边形
import matplotlib.pyplot as plt # Matplotlib 库,用于绘图
from PIL import Image # 用于图像处理
def render_pdf_page(file_path, doc_list, page_number):
# 打开 PDF 文档并加载指定页面
pdf_doc = fitz.open(file_path)
pdf_page = pdf_doc.load_page(page_number - 1)
segments = [doc.metadata for doc in doc_list if doc.metadata.get('page_number') == page_number]
# 将 PDF 页面转换为位图格式
pix = pdf_page.get_pixmap()
pil_image = Image.frombytes('RGB', [pix.width, pix.height], pix.samples)
# 创建绘图环境
fig, ax = plt.subplots(figsize=(10, 10))
ax.imshow(pil_image)
# 定义类别到颜色的映射
category_to_color = {'Title': 'orchid', 'Image': 'forestgreen', 'Table': 'tomato'}
categories = set()
# 绘制章节标注框
for segment in segments:
points = segment['coordinates']['points']
layout_width = segment['coordinates']['layout_width']
layout_height = segment['coordinates']['layout_height']
category = segment.get('category', 'Other')
color = category_to_color.get(category, 'gold')
# 解包坐标为框
x0, y0 = points[0]
x1, y1 = points[2]
width = x1 - x0
height = y1 - y0
rect = patches.Rectangle((x0, y0), width, height, linewidth=2, edgecolor=color, facecolor='none')
ax.add_patch(rect)
# 标注类别
ax.text(x0, y0 - 5, category, fontsize=10, color=color, weight='bold', backgroundcolor='white')
categories.add(category)
plt.axis('off')
plt.show()
在前面的代码中,render_pdf_page 函数会打开给定的 PDF 文件,使用 PyMuPDF 渲染指定页面,并根据你的元素元数据指定的区域绘制矩形框。每种区域类型,例如标题、图片、表格等,都会分配不同颜色,以便视觉上更清楚。请确保你的 doc_list 中的坐标信息与 PDF 渲染所使用的坐标系统匹配,这样才能准确标注。
下面是一个很好的代码示例,用于缩放版面坐标,并生成匹配的可视化图例:
layout_height = segment['coordinates']['layout_height']
scaled_points = [(x * pix.width / layout_width, y * pix.height / layout_height) for x, y in points]
box_color = category_to_color.get(segment['category'], 'deepskyblue')
categories.add(segment['category'])
rect = patches.Polygon(scaled_points, linewidth=1, edgecolor=box_color, facecolor='none')
ax.add_patch(rect)
## 添加图例
legend_handles = [patches.Patch(color='deepskyblue', label='Text')]
for category, color in category_to_color.items():
if category in categories:
legend_handles.append(patches.Patch(color=color, label=category))
ax.axis('off')
ax.legend(handles=legend_handles, loc='upper right')
plt.tight_layout()
由于原始段落坐标是基于 PDF 页面版面比例的,因此需要根据页面实际像素宽度和高度进行缩放。指定页码后,程序会从文档列表中过滤出属于该页的段落,并在页面上绘制这些段落的标注框。
你可以使用下面的代码调用前面的函数来显示版面:
render_pdf_page(file_path,docs, 1)
生成的 PDF 如下:
图 1.21:一页教材内容,其中包含照片、图示和关于中国云冈石窟的文字讨论
Alex:哦,我明白了。根据版面,我们可以把同类型信息组织在一起。比如,我们可以把图中绿色图片版面组内的所有元素整体传给大模型,以生成与这张图片,也就是云冈石窟,相关的问答内容。如果没有精确的版面分析,这会相当困难。
Lewis:非常聪明。
使用 UnstructuredLoader 解析 PDF 页面中的表格
接下来,我们看看如何从 PDF 页面中读取表格信息。前面讨论的 PDF 文档不包含表格,因此我们将换用一个包含表格的 PDF 文档。该文件第 12 页包含山西省主要城市的数据,数据来源于维基百科。
首先,我们会使用与之前相同的方法,通过调用 render_pdf_page(file_path,docs, 12) 语句解析该 PDF 文档第 12 页的版面元素,并将其可视化,如下图所示。
图 1.22:一个表格显示了中国山西各城市 2020 年和 2010 年城区人口,以及城市本体人口
表格中的高亮元素表明,整个表格已经被成功解析,并且该元素类型是 Table:
图 1.23:一个表格列出中国山西各城市的城区人口,包括 2020 年和 2010 年数据
接下来,我们展示第 12 页所有元素的元数据:
page_number = 12
page_docs = [doc for doc in docs if doc.metadata.get('page_number') == page_number]
for doc in page_docs:
print('Metadata:')
for key, value in doc.metadata.items():
print(f' {key}: {value}')
部分输出如下:
图 1.24:三个 PDF 元素的元数据截图,显示了文件路径 ID 和类别
虽然这里输出了大量元数据信息,但关键在于,我们可以看到 metadata 中的 category 包含 Table,并且这个 Table 元素具有一个 parent_id。parent_id 会链接到表格的标题。这一点非常重要,因为表格不能脱离它所关联的标题而独立存在。表格元素可能只包含数字,而表格标题可能说明这些数字的含义。
例如,在比较山西省两组城市 GDP 时,如果一个表格标题是“2024 年各市 GDP”,另一个表格标题是“2025 年各市 GDP”,你就必须将表格中的元素与对应标题的元素关联起来。这一过程是 RAG 系统中的必要步骤。否则,只有数字却不知道对应年份,会导致检索结果缺乏准确性。
使用 ParentID 整合同一标题下的内容
如果需要将表格和它上方的标题文本整合在一起,可以按照以下步骤实现:
按 page_number 过滤:过滤出特定页面上的所有元素,例如第 12 页。
按 category 分类:识别 Table 和 Title 类型的元素,并判断 Title 是否位于 Table 上方,也就是比较它们的 y 坐标值。
整合表格和标题:将表格与离它最近的标题组合成一个结构,并输出整合后的信息。
Alex:不过,这个逻辑实现起来好像有点复杂。
Lewis:确实。由于 Unstructured 工具会自动保存父子关系,因此更直接的方法是定位 category 为 “Table” 的元素。对于每个表格,找到其 parent_id 对应的父元素,然后输出表格和父元素的组合内容。
接下来,使用下面的函数自动定位表格的子元素及其父元素,并将它们作为一个整体输出:
def find_tables_and_titles(docs):
results = []
for doc in docs:
## 检查文档是否为表格类型
if doc.metadata.get('category') == 'Table':
table = doc
parent_id = doc.metadata.get('parent_id')
## 查找表格对应的标题文档,parent_id 匹配 element_id
title = next((doc for doc in docs if doc.metadata.get('element_id') == parent_id), None)
if title:
results.append({'table': table.page_content, 'title': title.page_content})
return results
results = find_tables_and_titles(page_docs)
if results:
for result in results:
print('Found table and title:')
print(f'Title: {result['title']}')
print(f'Table: {result['table']}')
else:
print('No tables and titles found')
输出给出了城区列表:
Cities
2020 Urban area
2010 Urban area
2020 City proper
1
Taiyuan
4,071,075
3,154,157
5,304,061
2
Datong
1,792,696
1,362,314
3,105,591
3
Changzhi
1,168,042
653,125
3,180,884
4
Jinzhong
900,569
444,002
3,379,498
5
Linfen
696,393
571,237
3,976,481
6
Yuncheng
692,003
432,554
4,774,508
7
Yangquan
647,272
623,671
1,318,505
8
Jincheng
574,665
476,945
2,194,545
9
Shuozhou
420,829
381,566
1,593,444
10
Xinzhou
384,424
279,875
2,689,668
11
Xiaoyi
337,489
268,253
see Lüliang
12
Lüliang
335,285
250,080
3,398,431
13
Jiexiu
291,393
232,269
see Jinzhong
14
Huairen
247,612
see Shuozhou
15
Gaoping
243,544
213,460
see Jincheng
16
Yuanping
227,046
202,562
see Xinzhou
17
Hejin
225,809
175,824
see Yuncheng
18
Fenyang
207,473
149,222
see Lüliang
19
Huozhou
183,575
156,853
see Linfen
20
Yongji
182,248
179,028
see Yuncheng
21
Houma
175,373
137,020
see Linfen
22
Gujiao
159,593
146,161
see Taiyuan
这样,我们就成功地将表格中的数据和它的表头信息关联起来。有了表头信息,我们就可以根据用户问题检索相关表格。
我们再来看另一个父子关系组合的例子。在下面的截图中,你可以看到,Title 元素 External links 下共有四个子元素。如果需要将这四个子元素与它们的父元素,也就是标题,组合起来,并作为一个整合后的 chunk 输出,可以通过父子关系实现。
下面的代码示例演示了如何整合这些相关信息:
external_docs = [] # 创建一个列表,用来存储 external links 的子文档
parent_id = -1 # 将 parent_id 初始化为 -1
for doc in docs:
# 检查文档是否为 Title 类型,并且内容包含 'External links'
if doc.metadata['category'] == 'Title' and 'External links' in doc.page_content:
parent_id = doc.metadata['element_id']
external_docs.append(doc)
# 检查文档的 parent_id 是否匹配我们找到的标题 ID
if doc.metadata.get('parent_id') == parent_id:
external_docs.append(doc) # 将所有属于该标题的子文档加入结果列表
for doc in external_docs:
print(doc.page_content)
至此,我们完成了对 PDF 文件解析、加载和生成的讨论。
总结
到这里,Lewis 认为,最常用、最重要,同时也最具启发性的数据导入场景,都已经覆盖到了。Lewis 选择的工具远远谈不上全面,而是聚焦于解决特定场景和问题。至于更多文件类型、工具和场景,就需要大家继续探索。
项目实践中可能出现的其他场景包括:
导入代码块。可参考 LangChain 关于加载代码的文档,这部分也会在第 2 节中涉及。
清理 PDF 文档中的冗余信息,例如页眉、页脚等。可参考 Unstructured 文档中的文件清理说明。
连接各种数据库和云服务数据平台。可参考 LangChain 和 LlamaIndex 的集成接口部分。
导入音频和视频数据。可参考相关多模态大模型。
Alex:还有这么多要学啊!
Lewis:当然。你们看到的只是更大图景中的一部分。我们只是在众多文件类型、场景和工具中,动手处理了一小部分,也只解释了一点点。关于 RAG 的数据导入和解析,还有大量知识等待你们去发现。
Alex:我们学到的只是沧海一粟。
Anna:大家应该学会从小处看见全局。
Lewis:你们确实理解了我想表达的意思。
在下一章中,我们将看到如何把长文本划分成合适大小的片段,以便进行嵌入、索引和存储,并提升检索准确性。你将学习文本分块,也称为文本切分。