Fork me on GitHub

2025年3月

实战 Model Context Protocol

2024 年 11 月 25 日,Anthropic,就是 Claude 背后的那家公司,推出了一个名为 MCP 的开放协议,它的全称为 Model Context Protocol(模型上下文协议),用于标准化大模型与各类外部工具和数据源之间的交互。

这个协议自推出以来,在 AI 圈一直不温不火,很多人认为 MCP 只是套壳的 API,并没有什么特别之处。但是近期,随着 Manus 的爆火,MCP 的概念在社区中又逐渐流行开来,使用 MCP 打造一款本地的 Manus 成了大家乐此不疲的话题。

MCP 架构

为了搞清楚什么是 MCP,让我们先来了解下它的基本架构。下面这张图非常形象地展示了 MCP 的基本架构(图片来源):

mcp-overview.png

可以看到 MCP 采用了非常经典的 C/S 架构(客户端/服务器),主要包括三个部分:

  • 主机(Host): 一般是基于大模型的 AI 应用,比如 Claude Desktop、ChatGPT Desktop、Cursor 等桌面应用,需要访问外部数据或工具;
  • 客户端(Client):内置在应用中,与 MCP 服务器建立一对一的连接;
  • 服务器(Server):连接本地或远程的数据源,提供特定功能;

    • 本地数据源:文件或数据库;
    • 远程服务:外部 API 或互联网服务;

MCP 协议将所有的外部数据或工具以一种统一的方式接入 AI 应用,这就好比 USB-C 接口,将各种不同的电子设备统一成一种接口,从而让用户不再为准备各种各样不同的线缆插头而烦恼。简单说,MCP 就像一座桥梁,它本身不处理复杂逻辑,只负责协调 AI 应用与外部资源之间的信息流动。

MCP 和 API 的区别

在推出 MCP 之前,AI 应用如果要对接外部工具,通常需要单独整合多个不同的 API,每个 API 的接口可能都各不相同,认证方式和错误处理也可能不同,极大地增加了开发复杂度和维护成本。

所以说,传统 API 就像不同的门,每扇门都有一把不同的钥匙,而 MCP 像一把万能钥匙,AI 应用开发者只要集成了这个万能钥匙,就可以打开任意的门。下面是 MCP 和传统 API 的对比:

mcp-vs-api.png

MCP 初体验

接下来,让我们在 Claude Desktop 中体验下 MCP 是如何工作的。

首先,下载并安装 Claude for Desktop,注册并登录账号后,确保 Claude 能正常对话:

claude-hi.png

然后,打开 Settings -> Developer 配置页面:

claude-settings.png

点击 Edit Config 按钮,进入 Claude 配置文件所在目录,打开 claude_desktop_config.json 配置文件,输入如下内容:

{
    "mcpServers": {
        "filesystem": {
            "command": "npx",
            "args": [
                "-y",
                "@modelcontextprotocol/server-filesystem",
                "/Users/aneasystone/Downloads/demo"
            ]
        }
    }
}

这是官方开发的 Filesystem MCP Server,用于操作你的本地文件,比如读取、编辑、搜索等。最后一个参数是文件路径,表示只允许 Claude 访问这个目录,可以添加一个或多个。

注意这里通过 npx 命令启动 MCP Server,所以需要提前安装 Node.js,使用 node --version 确认你的电脑上是否具备 Node.js 环境。

配置好 Filesystem MCP Server 之后,重启 Claude Desktop 应用,Claude Desktop 在启动时会自动加载所有的 MCP Server(其实就是为每个 Server 启动一个独立的进程,运行配置文件中的命令)。加载成功后,在对话框右下角会看到一个小锤子的图标:

claude-mcp-hammer.png

点击小图标,可以看到 Filesystem MCP Server 自带的所有工具列表:

claude-mcp-tools.png

这时我们就可以在对话时让 Claude 调用这些工具了:

claude-file-list.png

Claude 在调用工具之前会提醒用户,只有当用户确认允许后才会真正执行相应操作。

运行 npx 默认是从 Node.js 官方仓库下载包,有时会非常慢,导致 Claude 加载 MCP Server 失败,可以通过环境变量将仓库地址改为国内的源:

{
    "mcpServers": {
        "filesystem": {
            "command": "npx",
            "args": ...
            "env": {
                "NPM_CONFIG_REGISTRY": "https://mirrors.huaweicloud.com/repository/npm/"
            }
        }
    }
}

MCP 开发者指南

上一节我们从用户视角体验了一把 MCP,直观的感受了 MCP 是如何将外部资源集成到 AI 应用的。接下来,我们将从开发者视角,介绍如何开发自己的 MCP Server 以及如何将 MCP 集成到自己的 AI 应用中。

开发 MCP Server

这一节我们将通过 MCP 官方提供的 SDK 实现一个简单的天气查询 MCP Server。

官方目前提供了 PythonTypeScriptJavaKotlin 四种 SDK 供开发者选择,这里我们使用 Pyhon SDK。

MCP Server 可以提供三种主要类型的能力:

这里我们暂时只关注工具。

首先,安装 mcp[cli] 依赖:

$ pip install --index-url https://pypi.tuna.tsinghua.edu.cn/simple/ mcp[cli]

然后,创建 mcp-server-weather.py 文件:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather")

@mcp.tool()
async def get_weather(city: str, date: str) -> str:
    """查询某个城市某个日期的天气.

    Args:
        city: 城市名称
        date: 日期
    """
    return '天气晴,气温25摄氏度'

if __name__ == "__main__":
    mcp.run(transport='stdio')

上面的代码可以说非常简单,先通过 FastMCP 初始化 MCP Server,然后通过 @mcp.tool() 注解定义工具,最后通过 mcp.run() 启动 MCP Server。

其中 transport 参数表示传输协议的类型,决定了客户端如何和 MCP Server 通信,MCP 默认支持两种传输协议

  • 标准输入和输出 (stdio):通过标准输入和输出流进行通信,这对本地集成和命令行工具特别有用;
  • 服务器发送事件 (SSE):通过 HTTP POST 请求支持客户端与服务器之间的流式通信;

这里 transport='stdio' 表示使用标准输入和输出流进行通信,至此,一个简单的 MCP Server 就开发好了。

接下来,打开 Claude 的配置文件 claude_desktop_config.json,添加 MCP Server 配置如下:

{
    "mcpServers": {
        "weather": {
            "command": "python3",
            "args": [
                "/path/to/mcp-server-weather.py"
            ]
        }
    }
}

重启 Claude for Desktop,在对话框右下角可以看到我们的工具已成功加载:

claude-mcp-my-tools.png

测试下效果:

claude-using-my-tools.png

探索 MCP Server

除了上面介绍的 Filesystem MCP Server 和自己开发之外,网上还有大量的 MCP Server 开箱即用,可以用于文件处理、数据分析、软件开发、浏览器自动化、沟通提效等等,这些官网提供的 例子 可以作为很好的入门。

此外,社区也有不少人将各种 MCP Server 收集在一起,比如 Model Context Protocol ServersAwesome MCP ServersMCP.soSmithery,感兴趣的同学可以逛逛看。

开发 MCP Client

上面我们体验了在 Claude Desktop 中通过 MCP Server 让大模型调用外部工具,实现更强大的功能,这很容易让人想起智能体的概念。智能体是目前最热门最前沿的研究方向之一,我曾在之前的博客中多次学习过相关的技术,比如在 大模型应用开发框架 LangChain 学习笔记(二) 中学习 OpenAI 的 Function Call 功能以及 LangChain 内置的几种 Agent 的使用,在 基于 LangGraph 创建智能体应用 中学习如何使用 LangGraph 开发智能体应用。

之前开发智能体应用时,我们要对不同的工具分别对接,现在有了 MCP,我们只需要对接 MCP 协议,将所有工具的对接统一化。这一节,我们将学习如何构建一个连接到 MCP 服务器的 AI 应用。

首先,安装 mcp[cli]openai 依赖:

$ pip install --index-url https://pypi.tuna.tsinghua.edu.cn/simple/ mcp[cli] openai

然后,创建 app.py 文件,程序大致的结构如下:

class MCPClient:
    ''' 客户端逻辑
    '''
    
async def main():
    client = MCPClient()
    try:
        await client.connect_to_server()
        await client.chat("查下合肥和杭州明天的天气")
    finally:
        await client.cleanup()

if __name__ == "__main__":
    asyncio.run(main())

其中 MCPClient 类实现了客户端的主要逻辑,包括两个核心方法,第一个是 connect_to_server() 方法:

    async def connect_to_server(self):

        # 通过 stdio 连接 MCP Server
        server_params = StdioServerParameters(
            command = "python",
            args = ["mcp-server-weather.py"],
            env = None
        )
        self.stdio, self.write = await self.exit_stack.enter_async_context(stdio_client(server_params))
        self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write))
        await self.session.initialize()

        # 列出所有工具
        response = await self.session.list_tools()
        print("\nConnected to server with tools:", [tool.name for tool in response.tools])

上面的代码通过 MCP SDK 提供的 stdio_client() 方法让我们以 stdio 方式连接到上一节开发的天气查询 MCP Server 上,并创建一个 ClientSession 会话对象;有了会话之后,我们就可以通过 self.session.initialize() 对会话进行初始化,通过 self.session.list_tools() 查询 MCP Server 的工具列表。

注意,在 MCP SDK 中大量使用了 async 关键字定义异步函数,异步编程允许你编写非阻塞的代码,从而提高程序的效率,尤其是在处理 I/O 操作时(如网络请求、文件读取等)。在调用这些异步函数时需要使用 await 关键字,同时我们的函数本身也得定义成异步函数。或者用 asyncio.run() 运行异步函数,它会启动一个事件循环,直到异步函数运行完成。

第二个是 chat() 方法:

    async def chat(self, query: str) -> str:
        
        # 组装 function call 参数
        response = await self.session.list_tools()
        available_tools = [{
            "type": "function",
            "function": {    
                "name": tool.name,
                "description": tool.description,
                "parameters": tool.inputSchema
            }
        } for tool in response.tools]

        messages = [{
            "role": "user",
            "content": query,
        }]
        while True:
            print(f"Sending messages {messages}")
            completion = self.openai.chat.completions.create(
                model="gpt-4o-mini",
                messages=messages,
                tools=available_tools
            )
            tool_calls = completion.choices[0].message.tool_calls
            if tool_calls == None:
                print(completion.choices[0].message.content)
                break
            
            messages.append({
                "role": "assistant",
                "content": "",
                "tool_calls": tool_calls
            })
            
            # 调用工具
            for tool_call in tool_calls:
                tool_name = tool_call.function.name
                tool_args = tool_call.function.arguments
                result = await self.session.call_tool(tool_name, json.loads(tool_args))
                print(f"[Calling tool {tool_name} with args {tool_args}]")
                print(f"[Calling tool result {result.content}]")
            
                messages.append({
                    "role": "tool",
                    "content": result.content,
                    "tool_call_id": tool_call.id
                })

这个方法比较简单,调用 OpenAI 接口进行对话,通过 Function calling 功能实现 MCP Server 的调用。

运行结果如下:

Connected to server with tools: ['get_weather']

[Calling tool get_weather with args {"city": "合肥", "date": "明天"}]
[Calling tool result [TextContent(type='text', text='天气晴,气温25摄氏度', annotations=None)]]
[Calling tool get_weather with args {"city": "杭州", "date": "明天"}]
[Calling tool result [TextContent(type='text', text='天气晴,气温25摄氏度', annotations=None)]]

明天合肥和杭州的天气都是晴天,气温大约在25摄氏度。

目前已经有很多客户端集成了 MCP,比如 CursorClineLibreChatRoo CodeWindsurf Editor 等,官方这里有一个 已集成 MCP 的应用列表,其中不乏有一些著名的开源项目,当我们实现 MCP Client 遇到问题时,不妨参考下他们的实现。

深入 MCP 原理

MCP Inspector 是一个用于测试和调试 MCP Server 的交互式开发工具,使用 MCP Inspector 可以让我们对 MCP 的工作原理有一个更深入的了解。

使用下面的命令启动 MCP Inspector:

$ npx -y @modelcontextprotocol/inspector python3 /path/to/mcp-server-weather.py

注意参数的后面是我们的 MCP Server 的启动命令。启动成功后,在浏览器输入 http://localhost:5173 就可以访问了:

mcp-inspector.png

在这里可以查看 MCP Server 的工具列表,也可以对工具进行调用。除了工具,也支持资源、提示以及其他功能的调试。

在页面左下方,可以查看调试的历史记录:

mcp-inspector-history.png

这里可以更深入的了解 MCP 的底层细节。MCP Client 和 MCP Server 之间的所有消息都遵循 JSON-RPC 2.0 格式,比如所有的请求格式如下:

{
  jsonrpc: "2.0";
  id: string | number;
  method: string;
  params?: {
    [key: string]: unknown;
  };
}

所有的响应格式如下:

{
  jsonrpc: "2.0";
  id: string | number;
  result?: {
    [key: string]: unknown;
  }
  error?: {
    code: number;
    message: string;
    data?: unknown;
  }
}

所以我们也可以在启动 MCP Server 之后,直接在 stdio 上输入这个格式的请求来调试。比如查询工具列表:

{"method":"tools/list","params":{},"jsonrpc":"2.0","id":1}

不过如果我们直接输入上面的请求,MCP Server 可能会报如下这个错误:

      | Traceback (most recent call last):
      |   File "/usr/local/lib/python3.13/site-packages/mcp/shared/session.py", line 326, in _receive_loop
      |     await self._received_request(responder)
      |   File "/usr/local/lib/python3.13/site-packages/mcp/server/session.py", line 148, in _received_request
      |     raise RuntimeError(
      |         "Received request before initialization was complete"
      |     )
      | RuntimeError: Received request before initialization was complete
      +------------------------------------

根据 MCP 的规范,为了确保适当的功能协商和状态管理,MCP Client 和 MCP Server 之间的通信遵循严格的 生命周期,整个生命周期可以分成三个阶段:初始化阶段、操作阶段和关闭阶段,如下图所示:

mcp-lifecycle.png

可以看出 MCP Client 向 MCP Server 发送请求,属于操作阶段,在进入操作阶段之前,我们必须先执行初始化。整个初始化的过程有点类似 TCP 的三次握手,首先,发送初始化消息:

{"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"claude-ai","version":"0.1.0"}},"jsonrpc":"2.0","id":0}

如果初始化成功,我们可以得到类似这样的消息:

{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2024-11-05","capabilities":{"experimental":{},"prompts":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"tools":{"listChanged":false}},"serverInfo":{"name":"weather","version":"1.4.1"}}}

接着再发送初始化完成消息:

{"method":"notifications/initialized","jsonrpc":"2.0"}

三次握手之后,就算初始化完成了。接下来我们就可以发送请求了,比如调用工具:

{"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"合肥", "date":"明天"}},"jsonrpc":"2.0","id":2}

参考

更多

Spring AI + MCP

打造本地 Manus

通过内置深度思考,文件处理,联网搜索,浏览器,代码解释器等技能实现类 Manus 智能体。

盘点 Python 中那些 PDF 解析库

使用 RAG 实现企业私域知识问答是目前最流行也是最前沿的大模型技术之一,在 高级 RAG 技术学习笔记 这篇笔记中,我介绍了很多种不同的高级 RAG 技术,其关注点更多的是如何检索私域知识以及如何将检索结果灌输到大模型中,并没有深入如何获得这些私域知识。

现实中,绝大数企业私域知识都是非结构化的,散落在各种网页、文档或邮件附件里,如果能将这些内容解析出来,对企业来说无疑是巨大的价值。而在这些非结构化的文档中,PDF 文档占比很高,使得 PDF 解析对 RAG 至关重要。

PDF 全称 Portable Document Format(可移植文档格式),于 1993 年由 Adobe 公司开发,鉴于其跨平台性、高安全性、开放标准、可搜索性和可访问性等优势,已经成为全球范围内广泛使用的文件格式。Python 中有着大量的 PDF 解析库,这篇笔记对常用的 PDF 解析库做一个盘点,方便自己后期技术选型时做参考。

pypdf

pypdf 是一个免费且开源的纯 Python PDF 库,能够分割、合并、裁剪和转换 PDF 文件的页面,可以向 PDF 文件添加自定义数据,对 PDF 文件进行加密和解密。当然,pypdf 还可以从 PDF 中提取文本、图片、附件、批注和元数据等。

from pypdf import PdfReader

reader = PdfReader("./pdfs/example.pdf")
number_of_pages = len(reader.pages)
print('Total %d pages.' % (number_of_pages))
for i in range(number_of_pages):
    print('----- Page %d -----' % (i+1))
    page = reader.pages[i]
    text = page.extract_text()
    print(text)

    for count, image_file_object in enumerate(page.images):
        with open(str(count) + image_file_object.name, "wb") as fp:
            fp.write(image_file_object.data)

可以看出 pypdf 的用法较为简单,我们只能拿到每一页的文本和图片内容,拿不到更多的详细信息,比如文本字体和大小,块位置等,这些信息在处理复杂场景时是必不可少的。所以 pypdf 只适合 PDF 的内容比较规整的场景。

pdfminer.six

pdfminer.six 也是一个纯 Python 编写的 PDF 库,专注于获取和分析文本数据。

pdfminer 最初由 Euske 开发,但是只支持 Python 2,不支持 Python 3,于是社区在他的基础上引入了 six,这是一个无需修改代码,就可以同时兼容 Python 2 和 3 的库,所以叫做 pdfminer.six

使用 extract_text 方法实现类似 pypdf 的效果,直接返回文本:

from pdfminer.high_level import extract_text

text = extract_text("./pdfs/example.pdf")
print(text)

或者使用 extract_pages 方法提取元素的详细信息,包括文本的精确位置、字体、大小或颜色:

from pdfminer.high_level import extract_pages

for page in extract_pages("./pdfs/example.pdf"):
    for element in page:
        print(element)

这里的元素可能是 LTTextBoxLTFigureLTLineLTRectLTImage,它们的层次结构如下所示:

pdfminersix-layout.png

其中 LTTextBox 还可以继续遍历得到 LTTextLineLTTextLine 再继续遍历得到 LTChar

from pdfminer.high_level import extract_pages
from pdfminer.layout import LTTextContainer, LTChar

for page in extract_pages("./pdfs/example.pdf"):
    for element in page:
        if isinstance(element, LTTextContainer):
            for text_line in element:
                for character in text_line:
                    if isinstance(character, LTChar):
                        print(character.get_text())
                        print(character.fontname)
                        print(character.size)
        else:
            print(element)

布局分析

PDF 文件和 .txt 或 Word 在格式上有着很大的不同,它不包含任何类似于段落、句子甚至单词的内容。它由一系列对象及其结构信息组成,这些对象共同描述一个或多个页面的外观,可能还附带有其他交互元素和更高级别的应用程序数据。这使得从 PDF 文件中提取有意义的文本片段变得困难,组成段落的字符与组成表格、页面底部或图表描述的字符没有任何区别。

上一节我们知道,通过 pdfminer.six 可以拿到元素的位置信息,通过这些位置信息我们可以重建句子或段落的布局。布局分析由三个不同阶段组成:将字符分组为单词和行,然后将行分组为框,最后以层次结构方式将文本框分组。这是一种最经典的基于规则的 布局分析算法(Layout analysis algorithm)

布局分析依赖于几个重要参数,比如字符间距、行间距、行重叠等,这些参数都是 LAParams 类 的一部分。

更多说明请参考 Converting a PDF file to text 这篇文档。

pypdfium2

PDFium 被认为是开源世界中最高质量的 PDF 渲染引擎之一,它最初是基于福昕软件(Foxit Software)的 PDF SDK 开发的,在 2014 年被 Google 开源。PDFium 支持多种操作系统,包括 Windows、macOS、Linux 等,它还被编译到 iOS、Android 等移动平台上,支持跨平台应用;除了基本的 PDF 渲染功能,PDFium 还支持生成、编辑、文本提取、搜索、注解、表单填充等高级功能。PDFium 是一个高效、可靠的 PDF 渲染引擎,广泛应用于 Chrome 浏览器和其他第三方项目中。其开源性质和丰富的功能使其成为处理 PDF 文档的理想选择,由于基于 C++ 开发,处理大文件速度优于纯 Python 库。

pypdfium2 是 PDFium 库的 Python 3 绑定,它提供了一些辅助方法简化 PDFium 库的使用,同时原始的 PDFium/ctypes API 仍然可访问。下面的示例代码演示了如何通过 pypdfium2 的 get_text_bounded() 方法将 PDF 中的文本提取出来:

import pypdfium2 as pdfium

pdf = pdfium.PdfDocument("./pdfs/example.pdf")
for i in range(len(pdf)):
    print('----- Page %d -----' % (i+1))
    page = pdf[i]
    textpage = page.get_textpage()
    text_all = textpage.get_text_bounded()
    print(text_all)

和 pdfminer.six 一样,我们也可以通过 get_rect() 获取每个文本块的位置:

textpage = page.get_textpage()
rect_count = textpage.count_rects()
print(rect_count)
for i in range(rect_count):
    rect = textpage.get_rect(i)
    print(rect)
    text = textpage.get_text_bounded(rect[0], rect[1], rect[2], rect[3])
    print(text)

得到文本块的位置后,就可以使用布局分析算法对版面进行分析,比如行检测(通过 Y 坐标差异判断是否在同一行)、列检测(通过 X 坐标差异判断是否属于同一列)或表格边界检测等等。

此外,PDFium 还提供了一个 render() 方法,可以方便地将 PDF 转换为图片:

bitmap = page.render(
    scale = 1,    # 72dpi resolution
    rotation = 0, # no additional rotation
)
pil_image = bitmap.to_pil()
pil_image.save('x.png')

pdfplumber

pdfplumber 是另一个用于解析 PDF 的 Python 库,它基于 pdfminer.six 构建,但提供了更简洁的 API 和对复杂布局(尤其是表格)的更好支持。它的核心功能如下:

  • 文本提取:提取 PDF 中的文本(包括位置、字体等元数据),支持按页面、按区域或按特定条件提取;
  • 表格提取:自动检测和提取 PDF 中的表格数据(支持合并单元格、多行文本等复杂结构);
  • 页面操作:获取页面尺寸、裁剪页面区域,提取页面内的图像、线条、矩形等可视化元素;
  • 可视化调试:支持绘制页面中的文本、线条、表格框等元素,帮助用户调试提取逻辑。

pdfplumber 的基本用法如下:

import pdfplumber

path = "./pdfs/example.pdf"
with pdfplumber.open(path) as pdf:
    for i in range(len(pdf.pages)):
        print('----- Page %d -----' % (i+1))
        page = pdf.pages[i]
        text = page.extract_text()
        print(text)

pdfplumber 中有很多借鉴 pdfminer.six 的概念,比如通过 page.chars 可以访问页面中所有的文本字符,获取字体,大小,位置等信息:

page = pdf.pages[1]
for char in page.chars:
    print(char['text'], char['fontname'], char['size'], char['x0'], char['y0'], char['x1'], char['y1'])

除此之外,通过 page 还能访问很多其他对象,比如 .lines 代表线,.rects 代表矩形框,.curves 代表曲线(无法识别成线或矩形的连接点),.images 代表图像,.annots 代表 PDF 批注,.hyperlinks 代表链接等。

布局分析

pdfplumber 基于 pdfminer.six 构建,所以也具备 pdfminer.six 布局分析的功能,打开 PDF 时传入 laparams 参数即可:

laparams = {
    "line_overlap": 0.5,
    "char_margin": 2.0,
    "line_margin": 0.5,
    "word_margin": 0.1,
}
with pdfplumber.open(path, laparams=laparams) as pdf:
    pass

表格提取

pdfplumber 的一大亮点是它的表格提取功能,可以处理比较复杂布局的表格。它的基本用法如下:

page = pdf.pages[2]
tables = page.extract_tables()
for table in tables:
    print(table)

得到的表格是一个二维数组,类似下面这样:

[
    ['姓名', '学号', '学科', '成绩'], 
    ['小明', '001', '语文', '98'], 
    ['小明', '001', '数学', '97'], 
    ['小华', '002', '语文', '94'], 
    ['小华', '002', '数学', '99'], 
    ['小红', '003', '语文', '100'], 
    ['小红', '003', '数学', '95']
]

可以直接加载到 pandas 的 DataFrame 中对表格数据进行处理:

import pandas as pd
df = pd.DataFrame(table[1:], columns=table[0])
print(df)

pdfplumber 的表格提取算法参考了 Anssi Nurminen 的这篇论文 《Algorithmic Extraction of Data in Tables in PDF Documents》,大概的思路如下:

  1. 首先查找页面中明确定义的行,或者根据单词对齐找到隐含的行;
  2. 合并重叠或接近重叠的线条;
  3. 找出这些线条的交点;
  4. 找到使用这些交点作为顶点的最细粒度的矩形,即单元格;
  5. 将连续单元格分组到表中。

和布局分析类似,这种基于规则的算法必然也提供了大量的规则参数,可以通过 table_settings 传入:

table_settings = {
    "vertical_strategy": "lines",
    "horizontal_strategy": "lines",
    "snap_tolerance": 3,
    "join_tolerance": 3
}
tables = page.extract_tables(table_settings=table_settings)
for table in tables:
    print(table)

官方仓库里提供了几个示例可供参考,其中 extract-table-ca-warn-report.ipynb 演示了表格提取的基本操作,extract-table-nics.ipynb 演示如何使用可视化调试查找最佳的表提取设置。

可视化调试

pdfplumber 的另一大亮点是它可以将页面转换为 PageImage 对象(有趣的是,这一步是通过 pypdfium2 实现的),然后在 PageImage 将 chars、lines、rects 绘制出来,通过可视化页面,可以更直观地理解页面的布局结构。

绘制文本解析结果:

im = page.to_image()
im.draw_rects(page.extract_words()).save('extract_words.png')

extract_words.png

绘制表格解析结果:

im = page.to_image()
im.debug_tablefinder(table_settings={}).save('debug_tablefinder.png')

debug_tablefinder.png

PyMuPDF

MuPDF 是由 Artifex Software 公司开发的一个专注于 PDF 文档处理和渲染的开源库和工具集,具有轻量级、高性能的特点,适合嵌入式系统、移动设备和桌面应用集成。MuPDF 提供了多种语言接口,可以在 JavaScriptJava.NETPython 等语言中调用,PyMuPDF 就是 MuPDF 的 Python 绑定。

注意,MuPDF 采用 AGPL 许可证,商业用途需联系 Artifex 获取商业授权。

如果安装 PyMuPDF 时遇到编译错误,可以考虑如下方式安装:

$ pip install --only-binary=pymupdf pymupdf

下面是 PyMuPDF 的基本用法:

import pymupdf

doc = pymupdf.open("./pdfs/example.pdf")
number_of_pages = len(doc)
print('Total %d pages.' % (number_of_pages))
for i in range(number_of_pages):
    print('----- Page %d -----' % (i+1))
    page = doc[i]
    text = page.get_text()
    print(text)

其中,get_text() 用于从页面中提取文本,默认是以纯文本格式输出,还支持很多 其他的输出格式,比如以 HTML 格式输出:

html = page.get_text('html')

或以 JSON 格式输出,当我们想对 PDF 结构进一步分析时非常有用:

json = page.get_text('json')

PyMuPDF 的功能非常丰富,除了 PDF,它还支持大量其他的文件格式,包括 XPS、EPUB、MOBI 等电子书格式,以及 DOCX、XLSX、PPTX 等 Office 文档(Pro 版),这里 有一个表格列举了它的所有特性,并和其他几个 PDF 库进行对比。

表格提取

PyMuPDF 支持提取 PDF 中的表格数据:

from pprint import pprint
page = doc[2]
for t in page.find_tables():
    table = t.extract()
    pprint(table)

和 pdfplumber 一样,得到的表格是一个二维数组,可以直接加载到 pandas 的 DataFrame 中对表格数据进行处理。

Markdown 提取

在 RAG 中,为了实现文档内容的切片,文档版式分析的准确性显得至关重要,Markdown 作为一种语法简单、段落清晰的文本格式,在 LLM 和 RAG 场景下经常被使用。PyMuPDF 支持将 PDF 文件转换为 Markdown 格式,方便在 LLM 和 RAG 场景下使用,这需要安装 PyMuPDF4LLM 扩展库,它的功能特性如下:

  • 支持保留 PDF 中的大多数格式,比如表格、图片、链接、标题、段落、粗体、斜体、列表、代码块等都以 Markdown 语法保留下来;
  • 支持多列页面;
  • 支持图像和矢量图形提取,在 Markdown 文件中以图片语法;
  • 支持按页面分块输出;
  • 支持将输出作为 LlamaIndex Documents,方便在 LlamaIndex 中快速开发 RAG 应用;

PyMuPDF4LLM 扩展库的安装很简单:

$ pip install pymupdf4llm

使用也很简单:

import pymupdf4llm
md_text = pymupdf4llm.to_markdown("./pdfs/example.pdf", write_images=True)
print(md_text)

官方提供了 一些示例,可以实现 Chat PDF 的功能。

OCR 识别

PyMuPDF 集成了对 OCR 的支持,可以使用 OCR 对图片或文档页面进行识别,要使用该功能,需要先安装 Tesseract-OCR:

$ apt update
$ apt install tesseract-ocr

默认 Tesseract-OCR 只能识别英文,为了支持中文识别,还需要下载中文的训练数据,可以从 tesseract-ocr/tessdata 仓库下载 chi_sim.traineddata 文件,并将文件放在 /usr/share/tesseract-ocr/5/tessdata/ 这个目录下。

PyMuPDF 支持两种 OCR 策略,第一种是识别整个页面,第二种是提取页面中的图片进行识别。由于 OCR 非常缓慢,所以建议在处理 PDF 时每页只进行一次 OCR,当出现下面这些情况时可以使用第一种策略:

  • 页面完全被图片覆盖;
  • 页面上不存在文本;
  • 页面上有数千个小矢量图形;

示例代码如下:

page = doc[3]
textpage = page.get_textpage_ocr()
text = textpage.extractText()
print(text)

这里通过 page.get_textpage_ocr() 方法将 Page 转换为 TextPage 从而方便后续我们提取文本。

第二种策略是识别单独的图片,我们先从页面中将图片提取出来:

page = doc[3]
for img in page.get_images():
    xref = img[0]
    data = doc.extract_image(xref)['image']
    pix = pymupdf.Pixmap(data)

这里先通过 page.get_images() 获取页面中的所有图片,注意返回的是图片的 xref 引用信息,需要通过 doc.extract_image() 提取出图片的原始数据,最后再转换为 Pixmap 格式,只有这种格式才能 OCR 识别。得到 Pixmap 之后,我们可以将其保存成本地文件:

    pix.save('x.png')

也可以对其进行 OCR 识别:

    pix.pdfocr_save('x.pdf', language='eng')

这个方法将 Pixmap 保存为 1 页的 PDF 文件,该页面看起来像原始图片,具有相同的宽度和高度,但是它包含了一个被 Tesseract 识别的文本层,可以使用常规文本提取和搜索方法进行提取和搜索。当然这样做比较繁琐,会生成大量的临时文件,我们也可以直接在内存中进行读取:

    bytes = pix.pdfocr_tobytes()
    imgdoc = pymupdf.open("pdf", bytes)
    page = imgdoc[0]
    text = page.get_text()
    print(text)

还有另一种方式可以将 Page 转换成 Pixmap

page = doc[0]
pix = page.get_pixmap(dpi=300)
pix.save('x.png')
pix.pdfocr_save('x.pdf', language='chi_sim')

注意这里的 dpi=300 参数,可以保证生成的图片足够清晰,模糊的图片可能导致 Tesseract 识别失败。

Tabula vs. Camelot

上面提到 pdfplumber 和 PyMuPDF 都具备表格提取功能,关于表格提取,还有两个库不得不提,那就是 TabulaCamelot

Tabula 是一款专门用于提取 PDF 中表格的工具,它本身是一个基于 Java 开发的本地 Web 应用。首先上传 PDF 文件,手动选择或自动检测表格区域:

tabula-select-table.png

然后将选择的表格区域提取成结构化的数据:

tabula-extract-table.png

Tabula 实现了两种表格提取算法:一种叫 Stream 模式(参考 BasicExtractionAlgorithmNurminenDetectionAlgorithm,实际上也是借鉴了 Anssi Nurminen 那篇论文中的思路),另一种叫 Lattice 模式(参考 SpreadsheetExtractionAlgorithm);Stream 模式通过查找列之间的空白来确定表格,而 Lattice 模式则是查找列之间的边界线,如果单元格之间有明确的边界线,建议选择 Lattice 模式,当其中一种算法无法提取表格数据时,可以切换到另一种尝试。

同时,Tabula 也开源了对应的 SDK 方便其他开发者集成,核心仓库是 tabula-java,也有社区维护的 Python 绑定 tabula-py,基本用法如下:

import tabula

dfs = tabula.read_pdf(
    "./pdfs/table.pdf",
    pages = '1'
)
print(dfs[0])

注意:由于 Tabula 是基于 Java 实现的,所以要安装 JVM 环境。

Camelot 是另一款 PDF 表格提取工具和库,和 Tabula 非常类似,它是纯 Python 实现,不用依赖 JVM 环境。而且相比于 Tabula 它提供了更多的 高级选项,能应付更多更复杂的场景。这里 有一份 Camelot 和 Tabula 的详细对比,根据对比结果,Camelot 在多个场景下效果都比 Tabula 要好。

Camelot 用起来和 Tabula 也很像,如下:

import camelot

tables = camelot.read_pdf(
    './pdfs/table.pdf',
    pages = "1",
    flavor = "stream"
)
print(tables[0].df)

其中 flavor = "stream" 表示使用 Stream 模式,同样的,Camelot 也支持 Lattice 模式,不过它的实现有些不同(参考 lattice.py),它首先通过 Ghostscript 或 Pdfium 将 PDF 页面转换为图像,然后使用 OpenCV 的图像学算法,获取水平和垂直线段;推荐阅读 Camelot 的官方文档 How It Works,这篇文档带我们详细了解 Lattice 是如何一步一步地处理 PDF 页面的。此外,这篇文档还介绍了 Camelot 的另两种模式:Network 模式Hybrid 模式,此处不再赘述。

除了 Camelot 和 Tabula,还有很多类似的库也用于表格提取,比如 pdftablespdf-table-extract 等。

pikepdf

pikepdf 是另一个 Python PDF 解析库,帮助开发者读取、写入和修改 PDF 文件。这个库的取名很有意思:

Python + qpdf = "py" + "qpdf" = "pyqpdf"

可以看出 pikepdf 基于 qpdf 开发,而 qpdf 是一个使用 C++ 编写的功能强大的 PDF 操作和修复库。

使用 pikepdf 解析 PDF 不是一件容易的事,因为它使用了一些比较低级的概念,比如 对象模型,这种设计使得用户需要对 PDF 的内部结构和规范有一定的了解,才能高效地使用该库。

当我们准备上手 pikepdf 的入门示例时,我们会发现它甚至都 没有提取文本的能力,只能访问页面中的 内容流(Content Streams)

import pikepdf

with pikepdf.open('./pdfs/example.pdf') as pdf:
    page = pdf.pages[0]
    instructions = pikepdf.parse_content_stream(page)
    data = pikepdf.unparse_content_stream(instructions)
    print(data.decode())
    

解析出来的结果类似于下面这样的被称为 指令(Instructions) 的特殊字符串:

q
1 0 0 -1 0 841.9 cm
q
0 0 595.3 841.9 re
W*
n
0.12 w
2 M
2 J
2 j
0 0 0 RG

对 PDF 底层数据结构感兴趣的同学可以看看 《PDF Explained》 这本电子书。

强烈不推荐从内容流中解析文本,因为这意味着你需要手动处理字体、字号、编码等一系列问题,这只适合部分硬核玩家,我们不如直接使用其他现成的 PDF 解析库。不过我们可以使用 pikepdf 处理图片,比如提取、替换、删除等,下面是提取图片的一个示例:

    page = pdf.pages[3]
    for key in page.images:
        rawimage = page.images[key]
        pdfimage = pikepdf.PdfImage(rawimage)
        pdfimage.extract_to(fileprefix='x')

OCRmyPDF

OCRmyPDF 的特点是通过 OCR 识别图像中包含的文本,并为 PDF 中的图像添加一个文本层,使得图像 PDF 也可以被搜索。它的工作流程大致如下:

  • 首先通过 pikepdf 处理 PDF,修复 PDF 中可能存在的语法错误,并将 PDF 拆分成单页;
  • 然后使用 Ghostscript-dSAFER 模式对 PDF 的每一页进行光栅化;光栅图也叫做位图、点阵图、像素图,可以方便的进行 OCR 识别;
  • 接着使用 Tesseract 对光栅化的图像执行 OCR 识别;
  • 最后将识别的结果和原始的 PDF 进行合成,生成一个新的 PDF 文件。

默认情况下,OCRmyPDF 生成的文件是 PDF/A 格式,这是 PDF 规范的子集,旨在用于归档(“A” 代表归档)。PDF/A 与 PDF 的主要区别在于去掉了很多复杂的功能,例如嵌入式 Javascript、视频、音频和对外部字体的引用,因此它可能更安全。

OCRmyPDF 可以通过命令行工具使用,也可以通过 API 集成在 Python 代码中:

import ocrmypdf

ocrmypdf.ocr('./pdfs/example.pdf', 'output.pdf', skip_text=True)

正如前文所述,OCRmyPDF 依赖于 Ghostscript 处理页面,所以我们需要先安装 Ghostscript,不过当前使用 apt install ghostscript 命令安装的是 10.0.0 版本,是个有问题的版本,无法在 OCRmyPDF 中直接使用。如果你也遇到和我一样的问题,可以通过下面的步骤手动编译源码来安装:

$ wget https://github.com/ArtifexSoftware/ghostpdl-downloads/releases/download/gs10040/ghostscript-10.04.0.tar.gz
$ tar zxvf ghostscript-10.04.0.tar.gz
$ cd ghostscript-10.04.0
$ ./configure
$ make install

上面的 skip_text=True 参数表示跳过有文本的页面,只处理有图片的页面。要注意的是,如果页面既有文本又有图片,也会跳过,可以设置 force_ocr=True 参数强制识别所有页面:

ocrmypdf.ocr('./pdfs/example.pdf', 'output.pdf', force_ocr=True)

详细的参数说明可以参考官网的使用手册

打开生成的 PDF 文件,如果一切正常,可以发现图片上的文字不仅可以搜索,也可以用鼠标框选复制(而且框选的位置和图片中文字的位置非常吻合,一点违和感都没有):

ocrmypdf.png

此外,OCRmyPDF 通过 pluggy 实现了插件机制,我们可以基于插件机制实现如下功能:

  • 添加新的命令行参数;
  • 执行 OCR 之前增加自己的判断逻辑;
  • 在 OCR 识别或 PDF 生成之前对图片进行修改;
  • 用其他 OCR 引擎替换 Tesseract OCR;
  • 用其他光栅化或 PDF/A 生成器替换 Ghostscript;

感兴趣的同学可以参考官网的插件文档

markitdown

markitdown 是微软开源的一款 Python 库,旨在将各种文件格式转换为 Markdown。该库的一大特色是支持大量的文件格式,包括:Word、PPT、Excel、PDF、HTML、JSON、XML、CSV、ZIP、图像、音频、URL,等等等等,因此人气非常高,目前在 Github 上收获了超过 39k 的 Star。

使用 markitdown 非常简单,只需要 4 行代码:

from markitdown import MarkItDown
md = MarkItDown()
result = md.convert("./pdfs/example.docx")
print(result.text_content)

上面的代码将 Word 文档转换为 Markdown 格式,支持保留标题、表格、图片等 Markdown 语法,对 LLM 和 RAG 场景非常友好。深入研究 markitdown 的源码可以发现,它集众家之所长,使用了大量其他流行的开源库,比如:

尽管 markitdown 支持这么多的文件格式,但是它对 PDF 的解析效果并不好,它是直接使用 pdfminer.high_level.extract_text() 提取 PDF 中的文本的,所以标题、表格、图片等格式都丢失了,期待 markitdown 能稍微加强下对 PDF 的支持,能做到和 pymupdf4llm 一样的效果就完美了。

markitdown 的另一大特色是对图片的处理,不同于其他库喜欢用 Tesseract 做 OCR 识别,它通过多模态大模型(比如 gpt-4oQwen-VL 等)为图片生成详细描述:

from openai import Client
client = Client()
md = MarkItDown(llm_client=client, llm_model="gpt-4o")
result = md.convert("./pdfs/example.jpg")
print(result.text_content)

gptpdf

gptpdf 是另一个使用多模态大模型来解析 PDF 的库,它的核心思想非常简单:将 PDF 文件的每一页转换为图片,然后丢给大模型,让它将图片中识别到的文字转换为 Markdown 格式输出。它的 核心代码 不过 300 行,但几乎可以完美地解析排版、数学公式、表格、图片、图表等。

内置的 Prompt 也很简单:

使用markdown语法,将图片中识别到的文字转换为markdown格式输出。你必须做到:
1. 输出和使用识别到的图片的相同的语言,例如,识别到英语的字段,输出的内容必须是英语。
2. 不要解释和输出无关的文字,直接输出图片中的内容。例如,严禁输出 “以下是我根据图片内容生成的markdown文本:”这样的例子,而是应该直接输出markdown。
3. 内容不要包含在```markdown ```中、段落公式使用 $$ $$ 的形式、行内公式使用 $ $ 的形式、忽略掉长直线、忽略掉页码。
再次强调,不要解释和输出无关的文字,直接输出图片中的内容。

gptpdf 使用了作者自己开发的 GeneralAgent 库来调用大模型,支持 GPT-4oQwen-VLGLM-4VYi-VL 等:

from gptpdf import parse_pdf

content, image_paths = parse_pdf(
    pdf_path = "./pdfs/text+image.pdf", 
    output_dir = "./out",
    model = "gpt-4o",
)
print(content)

上面的代码运行成功后,会将输出结果写入到 out 目录下的 output.md 文件。

如果 PDF 中含有图片,gptpdf 的处理方式稍微有些不一样,它会首先使用 PyMuPDF 将图片区域用一个红色的框标记出来,像下面这样:

rect_image.png

然后将这个区域裁剪成子图保存起来,并让大模型在识别到这个图片时使用 Markdown 的图片语法 ![]() 替换该位置,内置的 Prompt 会加上如下内容:

图片中用红色框和名称(%s)标注出了一些区域。如果区域是表格或者图片,使用 ![]() 的形式插入到输出内容中,否则直接输出文字内容。
0_0.png, 0_1.png

参考