借助VL模型实现一个简易的pdf书签生成工具
有些pdf可能没有书签,导致阅读起来很不方便。故做了一个简易的pdf书签生成工具。代码已开源如下:
https://github.com/kv1830/simple_pdf_bookmark
https://gitee.com/daibaitu170/simple_pdf_bookmark
总体思路
- 1.把pdf转成图片,一页pdf就是一张图片。
- 2.逐个把图片送给一个多模态大模型,加上一堆提示词,让大模型返回该页的所有标题,并分好级别。
- 3.把标题转换成对应要求的书签格式保存到pdf中。
其实1、3两步并没什么难度,利用PyMuPDF就可以实现。相关代码在llm_bookmark/pdf_tools.py中。
pdf_2_pics函数把pdf转成图片,save_bookmarks函数把书签保存到pdf中。下面具体分享一下怎么调用大模型生成标题。
现在已经把pdf转成一张张图片了:
单页尝试
先选一页试一下,比如第一章的起始页(0013.png)。
我想让模型返回的结果就是标题级别和标题,如下:
[[标题级别,标题], [标题级别,标题]]
模型调用代码
先放上调用大模型的代码:
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.prompts import PromptTemplate
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model='qwen-vl-max-latest',openai_api_key='sk-xxxx', # 注意这里用你自己的api-keyopenai_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1", temperature=0
)image_dir = 'D:/学习/python/Python asyncio 并发编程 (马修·福勒)/'human_message_prompt = PromptTemplate.from_template(load_prompt_from_path("simple_prompt.txt"))prompt = [SystemMessage([{"type": "text", "text": "你是一个pdf书签助手."}]),HumanMessage([{"type": "image_url","image_url": {"url": f"data:image/png;base64,{encode_image(image_dir + '0013.png')}"},},{"type": "text", "text": human_message_prompt.invoke({}).text},])]response = model.invoke(prompt)
print(response.content)
ChatOpenAI的参数这块儿,我用的是阿里的多模态大模型,需要注册帐号并配置一个api key,把它填到openai_api_key这一项,api key的创建参见首次调用通义千问API。
模型我选的是qwen-vl-max-latest,对应model='qwen-vl-max-latest’这一项,模型选择及计费等信息参见视觉理解(Qwen-VL)。
当然也可以用其它厂商的模型,只要是兼容openai api的多模态模型都可以,本地vllm、ollama部署的也都ok,不过如果模型参数量比较小的话,可能性能有限。
human_message_prompt.invoke({}).text这里为什么写的这么麻烦,直接把提示词文本提取成str放过来不就行了?
主要是为了支持后面在提示词里面加占位符。
这里还用了两个函数load_prompt_from_path和encode_image,详细如下。
提示词
为了方便阅读代码,我把提示词放到一个txt里,用load_prompt_from_path这个函数读取提示词文本。提示词如下:
这是1个pdf扫描页,它来自一个pdf,我现在要给这个pdf生成书签,需要提取出其中的标题,主要是章节标题,请分级返回。
注意:
1.返回格式为json格式:
[[标题级别,标题], [标题级别,标题]]
样例1:
[[1, "第1章 langchain大语言模型基础"],
[2, "LangChain环境配置"],
[2, "在LangChain中使用LLMs"]]
2.严格按照上述返回格式,不要再给出额外内容。也不要用code wrapper(比如```json ```)
def load_prompt_from_path(prompt_path):with open(prompt_path, 'rt', encoding='utf-8', newline='') as f:return f.read()
encode_image
读取图片字节,然后转为base64编码文本,这样就可以传给大模型了,不过要注意对应大模型的token数上限,比如我用的qwen-vl-max-latest,最大输入129,024个token,单图最大16384,对于阿里这个模型来说,输入为图片时,28乘28像素的方块是一个token,所以实际token数就是用图片像素数除(28乘28)。如果得出的值超过了16384,则会自动resize。不过阿里的文档中说要开启vl_high_resolution_images才能支持高分辨率图像,而且似乎不支持在openai api中设置,否则最大只支持1280个token。这个我暂时没有仔细去测试,而且我在调用前其实就reize到1280个token了。
代码如下:
import base64
import math
from pathlib import Pathimport cv2
import numpy as npdef imread(path, flags=cv2.IMREAD_COLOR):return cv2.imdecode(np.fromfile(path, np.uint8), flags)def imwrite(path, im):try:cv2.imencode(Path(path).suffix, im)[1].tofile(path)return Trueexcept Exception:return False# 把opencv的两个函数替换一下,使得它们能读写中文路径的图片。
cv2.imread, cv2.imwrite = imread, imwritedef resize_by_tokens(image_path, min_pixels = 28 * 28 * 4, max_pixels = 1280 * 28 * 28):""":param image_path::param min_pixels: 图像的Token下限:4个Token:param max_pixels: 图像的Token上限:1280个Token:return:"""# 打开指定的PNG图片文件image = cv2.imread(image_path)# 获取图片的原始尺寸height, width = image.shape[:2]# 将高度调整为28的整数倍h_bar = round(height / 28) * 28# 将宽度调整为28的整数倍w_bar = round(width / 28) * 28# 对图像进行缩放处理,调整像素的总数在范围[min_pixels,max_pixels]内if h_bar * w_bar > max_pixels:# 计算缩放因子beta,使得缩放后的图像总像素数不超过max_pixelsbeta = math.sqrt((height * width) / max_pixels)# 重新计算调整后的高度,确保为28的整数倍h_bar = math.floor(height / beta / 28) * 28# 重新计算调整后的宽度,确保为28的整数倍w_bar = math.floor(width / beta / 28) * 28elif h_bar * w_bar < min_pixels:# 计算缩放因子beta,使得缩放后的图像总像素数不低于min_pixelsbeta = math.sqrt(min_pixels / (height * width))# 重新计算调整后的高度,确保为28的整数倍h_bar = math.ceil(height * beta / 28) * 28# 重新计算调整后的宽度,确保为28的整数倍w_bar = math.ceil(width * beta / 28) * 28else:return image_pathprint(f'resize {image_path} to {w_bar, h_bar}')resized_img = cv2.resize(image, (w_bar, h_bar), interpolation=cv2.INTER_LINEAR)image_path = Path(image_path)image_path_reize_dir = Path(str(image_path.parent) + '_rezied')image_path_reize_dir.mkdir(exist_ok=True)resized_img_path = image_path_reize_dir / image_path.namecv2.imwrite(str(resized_img_path), resized_img)return resized_img_path# base 64 编码格式
def encode_image(image_path, need_resize=True):if need_resize:image_path = resize_by_tokens(image_path, max_pixels=1280 * 28 * 28)with open(image_path, "rb") as image_file:return base64.b64encode(image_file.read()).decode("utf-8")
resize_by_tokens其实就是从阿里那个文档里抄过来的,1280乘28乘28像素对于A4纸大小的pdf来说绝对是够用了。
requirements.txt
先补充一下所有代码要用到的依赖
langchain==0.3.27
langchain_openai==0.3.28
PyYAML==6.0.2
pydantic==2.11.7
numpy==1.26.4
opencv-python==4.11.0.86
PyMuPDF==1.26.4
Pillow==11.3.0
调用结果
返回结果正确
resize D:/学习/python/Python asyncio 并发编程 (马修·福勒)/0013.png to (840, 1176)
[[1, "第1章 asyncio 简介"]]
但是有的时候会返回多余的内容
[[1, "第1章 asyncio 简介"], [2, "本章内容:"], [3, "asyncio 及其优势"], [3, "并发、并行、线程和进程"], [3, "全局解释器锁及其对并发性的影响"], [3, "非阻塞套接字如何仅用一个线程实现并发"], [3, "基于事件循环并发的工作原理"]]
temperature已经设0了,为什么还会有不一样的结果,暂时不是很清楚。。。
它把“本章内容”那块的东西当成第1章的子标题了。有时还会有其它的问题:
- 比如还是上面那张图,“第1章”和“asyncio 简介”中间隔了一条横线,部分情况下返回的标题会把“第1章”丢了
- 有时会把页眉里的内容当成标题。提前放个有页眉的图如下,左上角的“2 Python asyncio并发编程”有时会当成标题
- 目录页里自然全是标题,但我们明显不需要它们。
- 代码清单、图片标题、表格标题,我们也不需要。
- 其它误识别的情况。。。
先插个问题,你可能会说,如果能识别出目录页里所有的标题、级别、页号,那不是直接就可以生成pdf书签了吗?理论上不是不行,但是可能会有各种问题:
- pdf有缺页,导致就算提取出所有的标题、级别、页号,也可能导致页号对不上。尤其是在扫描版的pdf中,这是很常见的。
- 其实想正确提取出目录页所有的标题、级别、页号,现有的大模型可能是做不到的,我试过阿里的、豆包的、百度的都做不到,标题一旦多了,就会错漏。
可能有一些解决办法。
- 还是用大模型,但是让他少生成一些标题,倒是能提高成功率。比如只让他提取1级标题,这个准确率就很高。 但光提取1级标题还是不够用的。
- 专门针对目录页对模型进行微调,可能会大幅提升模型性能。
- 训练一个doclayout模型,让它能识别所有的目录格式(单列,双列,甚至更复杂的),然后结合ocr算法,提取出所有目录信息。
但是现在只是做一个简易的工具,就不搞那么复杂了。。。
针对上述说的各种误识别标题的问题,想到一个办法自然是在提示词中添加对应的提示。另外我们还可以参考思维链COT或者推理模型的模式,让模型自己先生成一些思考,使得最终生成标题的时候,更准确一些。
丰富提示词
现在把上述说的内容加到提示词中
这是1个pdf扫描页,它来自一个pdf,我现在要给这个pdf生成书签,需要提取出其中的标题,主要是章节标题,请分级返回。
注意:
1.不要把页眉当标题!
2.章节编号不要丢弃。
3.标题和正文字体不一样,标题单独占一行、字体更大、加粗,颜色也有可能有异。不要凭空从正文中杜撰出一个标题。不要把没加粗的正文说成加粗。
4.有的书籍,尤其是英文书籍,有章编号,但没有小节编号,而且小节可能实际上有多层级。此时需要通过之前的章、节的"内容概括"来推测层级关系。
5.请跳过封面、版权页、关于作者、致谢。遇到这些页只需要输出关键思考,最终答案为空即可。
6.注意,目录页也是直接跳过,不要提取任何标题。
7.代码清单、图片、表格这些都算在正文内容之中,故它们的小标题并不算作标题。
8.返回格式为json格式:
{{
"关键思考": "xxxx", #提取标题时一些重要的思考点,有助于正确提取标题,请逐页思考
"最终答案":
[[标题级别,标题,内容概括], [标题级别,标题,内容概括]]
}}
样例1:
{{
"关键思考": "图片顶端的\"langchain\"学习是页眉,不能把它当作标题。",
"最终答案":
[[1, "第1章 langchain大语言模型基础", "本章深入解析LangChain构建模块如何映射大语言模型概念,以及它们如何通过有效组合助力应用开发。"],
[2, "LangChain环境配置", "介绍如何配置好LangChain环境"],
[2, "在LangChain中使用LLMs", "LangChain提供了两个简单的接口来与任何LLM API提供商交互:聊天模型, LLMs"]]}}
样例2:
{{
"关键思考": "本页是目录页,直接跳过",
"最终答案":
[]}}
9.严格按照上述返回格式,不要再给出额外内容。也不要用code wrapper(比如```json ```)
修改点如下:
- 1-7就是提示哪些是标题,哪些不是。
- 8里面加了关键思考,并且注意“关键思考”的位置得在“最终答案”之前才有用。否则如果反过来的话,模型在生成“最终答案”的时候,它还没生成“关键思考”,那自然不可能利用到其中的信息。
- 另外8里面还加了样例,提示模型怎么生成关键思考,并且也是进一步强化“页眉不能当标题”,“跳过目录页”这两项。
- 最终答案里的标题信息变成了[标题级别,标题,内容概括],原因见提示词里的第4项。不过这个具体有多大提升,暂时没仔细研究。。
注意,前面说的占位符,是{占位符变量名}的形式,这里暂还没用到。但是json格式的{}为了不跟占位符混淆,采用双括号的形式,即{{和}}
用新的提示词重新调模型看看,结果如下:
resize D:/学习/python/Python asyncio 并发编程 (马修·福勒)/0013.png to (840, 1176)
{
"关键思考": "本页是第一章的起始页,顶部有明显的章节标题'第1章 asyncio 简介',字体较大且加粗,符合章节标题特征。下方的'本章内容'部分为正文内容的要点列表,并非标题。因此,仅提取'第1章 asyncio 简介'作为一级标题。",
"最终答案": [
[1, "第1章 asyncio 简介", "介绍asyncio的基本概念及其优势,涵盖并发、并行、线程和进程的区别,全局解释器锁的影响,非阻塞套接字的使用,以及基于事件循环的并发工作原理。"]
]
}
“关键思考”里的"下方的’本章内容’部分为正文内容的要点列表,并非标题。因此,仅提取’第1章 asyncio 简介’作为一级标题。"这一句对最终结果多半是有用的。
不过也不总是如此理想,比如就有过比较离谱的错误:
如上图,本来“思考”是对的,结果后来又思考错了。。。,好在这种错误不太多。
多页调用
下面这一页怎么确定标题级别呢?
可能模型知道1.1应该是2级目录,或者在提示词里加上相关的提示,但是似乎不够稳,也不够通用。因为不是所有的pdf都是在标题前面加上了1.1、1.1.1这样的序号的,比如很多英文书籍的官方pdf就顶多有章号,没有小节号。
所以我觉得最需要提示模型的,是告诉他之前的pdf页,有哪些标题、级别、大概内容是什么(内容概括),让模型自行决定当前页的各标题级别。
修改提示词
这是1个pdf扫描页,它来自一个pdf,我现在要给这个pdf生成书签,需要提取出其中的标题,主要是章节标题,请分级返回。
注意:
1.不要把页眉当标题!
2.章节编号不要丢弃。
3.标题和正文字体不一样,标题单独占一行、字体更大、加粗,颜色也有可能有异。不要凭空从正文中杜撰出一个标题。不要把没加粗的正文说成加粗。
4.有的书籍,尤其是英文书籍,有章编号,但没有小节编号,而且小节可能实际上有多层级。此时需要通过之前的章、节的"内容概括"来推测层级关系。
5.请跳过封面、版权页、关于作者、致谢。遇到这些页只需要输出关键思考,最终答案为空即可。
6.注意,目录页也是直接跳过,不要提取任何标题。
7.代码清单、图片、表格这些都算在正文内容之中,故它们的小标题并不算作标题。
8.返回格式为json格式:
{{
"关键思考": "xxxx", #提取标题时一些重要的思考点,有助于正确提取标题,请逐页思考
"最终答案":
[[标题级别,标题,内容概括], [标题级别,标题,内容概括]]
}}
样例1:
{{
"关键思考": "图片顶端的\"langchain\"学习是页眉,不能把它当作标题。",
"最终答案":
[[1, "第1章 langchain大语言模型基础", "本章深入解析LangChain构建模块如何映射大语言模型概念,以及它们如何通过有效组合助力应用开发。"],
[2, "LangChain环境配置", "介绍如何配置好LangChain环境"],
[2, "在LangChain中使用LLMs", "LangChain提供了两个简单的接口来与任何LLM API提供商交互:聊天模型, LLMs"]]}}
样例2:
{{
"关键思考": "本页是目录页,直接跳过",
"最终答案":
[]}}
9.当前面的起始标题级别,可能需要参照之前处理过的几页, 它们的标题如下:
{pre_titles}
10.严格按照上述返回格式,不要再给出额外内容。也不要用code wrapper(比如```json ```)
其实就是插入了第9项。
代码修改如下:
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.prompts import PromptTemplate
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model='qwen-vl-max-latest',openai_api_key='sk-xxxxxx',openai_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1", temperature=0
)image_dir = 'D:/学习/python/Python asyncio 并发编程 (马修·福勒)/'human_message_prompt = PromptTemplate.from_template(load_prompt_from_path("bookmark_single_page_with_pretitles_prompt.txt"))pre_titles = """[1, "第1章 asyncio 简介", "介绍asyncio的基本概念及其优势,涵盖并发、并行、线程和进程的区别,全局解释器锁的影响,非阻塞套接字的使用,以及基于事件循环的并发工作原理。"]"""
prompt = [SystemMessage([{"type": "text", "text": "你是一个pdf书签助手."}]),HumanMessage([{"type": "image_url","image_url": {"url": f"data:image/png;base64,{encode_image(image_dir + '0014.png')}"},},{"type": "text", "text": human_message_prompt.invoke({"pre_titles": pre_titles}).text},])]response = model.invoke(prompt)
print(response.content)
其实也只是加了pre_titles,调用结果如下:
resize D:/学习/python/Python asyncio 并发编程 (马修·福勒)/0014.png to (840, 1176)
{
"关键思考": "本页为正文内容的起始部分,属于第2章的开头。页面顶部的'2 Python asyncio 并发编程'是章标题,其中'2'为章节编号,'Python asyncio 并发编程'为章节名称,且该行字体较大、加粗,符合章节标题特征。接下来的'1.1 什么是 asyncio'为小节标题,编号明确,字体也大于正文,单独成行,符合小节标题格式。需注意不要将正文中的段落误认为标题。根据前一页标题推断,当前章节应为第2章,因此本页的章标题为第2章,小节为1.1。",
"最终答案": [
[1, "第2章 Python asyncio 并发编程", "介绍并发的基础知识,包括CPU密集型与I/O密集型工作的差异,进程和线程的基本概念,全局解释器锁(GIL)的影响,以及如何利用事件循环的非阻塞I/O实现并发。"],
[2, "1.1 什么是 asyncio", "解释asyncio的概念及其在解决同步程序中I/O阻塞问题中的作用,介绍异步编程的基本原理和优势。"]
]
}
它把页眉当标题了,再调一次:
{
"关键思考": "本页是正文内容的起始页,顶部有页眉'2 Python asyncio 并发编程',其中'2'为章节编号,但不是标题本身。真正的标题是'1.1 什么是 asyncio',该标题单独占一行,字体明显大于正文,且加粗,符合章节标题特征。前一页的标题为'[1, '第1章 asyncio 简介', '...']',因此当前标题应为二级标题,即小节标题。需要保留章节编号'1.1'作为标题的一部分。其余内容均为正文描述,不构成标题。",
"最终答案": [
[2, "1.1 什么是 asyncio", "介绍asyncio的基本概念,解释同步应用程序的阻塞问题,以及如何通过并发性解决I/O阻塞,引入异步编程模型在Python中的应用。"]
]
}
这次没把页眉当标题。并且也考虑到了“之前的内容”,把1.1的标题级别设为2。后面又调了几次,都没有再出错,但是前面出过错也是一个隐患,再观察一次图片发现,1.1节前面有一段文字,但它们的标题其实不在这一页,它们的标题其实就是“第1章 asyncio 简介"。会不会是因为这一段内容导致模型错把它们之上的页眉当成了标题?
故在提示词里又加插了一条:
3.页面的开头可能没有标题,只有内容,因为标题可能在前一页,这一部分内容不需要凭空生成一个标题。
这里就不放结果了,正如上面所说"后面又调了几次,都没有再出错"。
不过除了新增这一条提示词,我还有一个想法,那就是不只是把之前的标题传给模型,而是连带之前的pdf页图片一起传给模型,我的想法是:
- 让模型多看一些图片,有的图片有页眉,有的图片没页眉(比如第1章起始页),让模型“明白”到底什么是页眉。
- 如果模型一开始没有把页眉当图片,生成了正确答案。那么后面模型处理新页时,它能看到之前的图片、之前的正确答案,可能会延续之前一致的处理方式。(但如果模型一开始就是错的,那可能就。。。。)
关于”延续之前一致的处理方式“并不是我空想的,可以放上单图模式的整体处理结果,如下:
出现了各种问题:
- 有的把章标题识别成两段,比如”第3章“以及它下一行的标题
- 有的地方把代码清单标题提取出来,有的不提取
注:前期做过好几次实验,当时还没有把”代码清单不提取标题“加到提示词里,但是如果直接用多图模式的话,不加这一提示,也大概率不提取代码清单中的标题
但是多图模式的效果就好很多,效果图后面再放。
多图模式
继续修改提示词
这是几个pdf扫描页,它们来自一个pdf,我现在要给这个pdf生成书签,前几页用来参考,你只需要提取出最后一页中的标题,主要是章节标题,请分级返回。
注意:
1.不要把页眉当标题!
2.章节编号不要丢弃。
3.页面的开头可能没有标题,只有内容,因为标题可能在前一页,这一部分内容不需要凭空生成一个标题。
4.标题和正文字体不一样,标题单独占一行、字体更大、加粗,颜色也有可能有异。不要凭空从正文中杜撰出一个标题。不要把没加粗的正文说成加粗。
5.有的书籍,尤其是英文书籍,有章编号,但没有小节编号,而且小节可能实际上有多层级。此时需要通过之前的章、节的"内容概括"来推测层级关系。
6.请跳过封面、版权页、关于作者、致谢。遇到这些页只需要输出关键思考,最终答案为空即可。
7.注意,目录页也是直接跳过,不要提取任何标题。
8.代码清单、图片、表格这些都算在正文内容之中,故它们的小标题并不算作标题。
9.返回格式为json格式:
{{
"关键思考": "xxxx", #提取标题时一些重要的思考点,有助于正确提取标题,请逐页思考
"最终答案":
[[标题级别,标题,内容概括], [标题级别,标题,内容概括]]
}}
样例1:
{{
"关键思考": "图片顶端的\"langchain\"学习是页眉,不能把它当作标题。",
"最终答案":
[[1, "第1章 langchain大语言模型基础", "本章深入解析LangChain构建模块如何映射大语言模型概念,以及它们如何通过有效组合助力应用开发。"],
[2, "LangChain环境配置", "介绍如何配置好LangChain环境"],
[2, "在LangChain中使用LLMs", "LangChain提供了两个简单的接口来与任何LLM API提供商交互:聊天模型, LLMs"]]}}
样例2:
{{
"关键思考": "本页是目录页,直接跳过",
"最终答案":
[]}}
10.注意,只需要提取最后一页的标题,它的起始标题级别,可能需要参照前几页, 前几页的标题如下:
{pre_titles}
11.严格按照上述返回格式,不要再给出额外内容。也不要用code wrapper(比如```json ```)
主要是开头部分,以及第10项。另外第3项之前说过了。
修改代码
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.prompts import PromptTemplate
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model='qwen-vl-max-latest',openai_api_key='sk-xxxxx',openai_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1", temperature=0
)image_dir = 'D:/学习/python/Python asyncio 并发编程 (马修·福勒)/'human_message_prompt = PromptTemplate.from_template(load_prompt_from_path("bookmark_with_pretitles_prompt.txt"))pre_titles = """[1, "第1章 asyncio 简介", "介绍asyncio的基本概念及其优势,涵盖并发、并行、线程和进程的区别,全局解释器锁的影响,非阻塞套接字的使用,以及基于事件循环的并发工作原理。"]"""
prompt = [SystemMessage([{"type": "text", "text": "你是一个pdf书签助手."}]),HumanMessage([{"type": "image_url","image_url": {"url": f"data:image/png;base64,{encode_image(image_dir + '0013.png')}"},},{"type": "image_url","image_url": {"url": f"data:image/png;base64,{encode_image(image_dir + '0014.png')}"},},{"type": "text", "text": human_message_prompt.invoke({"pre_titles": pre_titles}).text},])]response = model.invoke(prompt)
print(response.content)
其实就是在HumanMessage里面新增了一张图,0013.png、0014.png都发送,顺序不要错。
调用结果如下:
resize D:/学习/python/Python asyncio 并发编程 (马修·福勒)/0013.png to (840, 1176)
resize D:/学习/python/Python asyncio 并发编程 (马修·福勒)/0014.png to (840, 1176)
{
"关键思考": "本页是第1章的延续内容,前一页已明确标题为'第1章 asyncio 简介',本页开始出现小节标题'1.1 什么是 asyncio',该标题字体明显大于正文,且单独成行、加粗,符合标题特征。根据章节编号规则,'1.1'表示这是第1章下的第一个小节,因此标题级别为2。页面中没有其他符合标题特征的内容,如代码清单、图片或表格标题均不作为标题处理。",
"最终答案": [
[2, "1.1 什么是 asyncio", "介绍asyncio的基本概念,解释同步程序中的阻塞问题,以及如何通过并发性解决I/O操作导致的性能瓶颈,引入异步编程模型在Python中的应用。"]
]
}
目录栈
到这里其实基本就介绍完毕了,就是一个很简易的方法。不过前面讲的多图模式,pre_titles都是硬编码在代码中的,用代码实现在一下自动的流程即可。实现起来也很简单,就是用一个栈的结构。比如:
1, "第1章 asyncio 简介"
2, "1.3 了解并发、并行和多任务处理"
3, "1.3.4 什么是多任务"
这就是一个目录栈(省略了”内容概括“和页号),而且是单调栈,(1, “第1章 asyncio 简介”)其实在栈底,其上是(2, “1.3 了解并发、并行和多任务处理”),再上是(3, “1.3.4 什么是多任务”),栈上面的标题级别比下面的大。如果新提取到标题,比如(2, “1.4 了解进程、线程、多线程和多处理”),那么就会删掉比它级别大的标题,再替换掉同级别的标题,更新为:
1, "第1章 asyncio 简介"
2, "1.4 了解进程、线程、多线程和多处理"
每次处理新的pdf页时,就由此目录栈取出对应的pdf页图片,以及这些图片对应的标题作为pre_titles。
这块的代码就不详述了,见本项目的llm_bookmark/bookmark.py。
缓存
另外说明一下,本项目是使用缓存的(演示代码中没用缓存),因为调大模型的开销说小也不是太小,400页pdf估计可能要个2-5块钱吧(为啥是估计,因为我测试了好几次,几本书,大概花了20几块钱),时间也不短,如果中途由于未知原因失败了,再完全重新来一次就比较尴尬了。
所以我把调模型的结果都缓存到项目根目录的cache文件夹中,文件名配置在conf.yaml里,具体见项目README.md。
其它特性、用法参见项目README.md.
最终效果
暂只发现1.5节的级别错了,之前已经说过它为啥错了,我再把离谱原因放一下。。。
其它思考
调多模态大模型的办法主要是简单,但是缺点就是会花更长的时间,比如这个400页的pdf,大概在1小时。有没有更快的方案呢?那肯定是有的,比如调用文档布局模型doclayout,获取每一页的标题,但是无法确定级别,像这个pdf中”第1章“和实际标题分两行,中间还有横线的,可能把上面的第1章误判为页眉,所以可能需要增加一些后处理代码。至于标题级别,可以考虑把所有标题传给一个纯语言的大模型,让它来判断标题级别。但是可能需要标题都有相应的序号,比如第1章、第1.1节这种,否则分级效果可能不好。并且如果标题太多,可能要考虑分段处理,或者一次性传所有标题,但是只让模型生成1、2级标题(因为有的模型上下文长度很长,比如64K,但输出只有8K甚至更短)。