
当业务里需要让大模型直接阅读 PDF 文档时我踩过不少坑把一份几十页的 PDF 直接丢给 LLM结果要么回答吞吞吐吐、只复述开头几页的内容要么干脆告诉你“内容过多无法处理”。一开始以为是模型不够聪明后来才意识到——问题出在“PDF 这个格式本身”和“LLM 的工作方式”之间存在一道天然鸿沟。人读 PDF 时可以跳跃式阅读先翻目录、直接定位到关键章节、扫一眼表格、再跳回正文。但 LLM 做不到这种“跳跃”它只能处理被喂进上下文窗口的那一段连续文本。这篇文章就围绕“LLMs Cant Jump”这个主题拆解大模型处理 PDF 时的能力边界并给出从文本抽取、分块到 RAG 检索的完整落地思路。内容覆盖概念解读、环境准备、可运行代码和常见问题排查既适合刚开始接触 LLM 的读者建立认知框架也适合正在做 PDF 知识库、文档问答系统的开发者直接参考。1. 为什么说 “LLMs Cant Jump”1.1 一个容易被忽略的能力边界很多人在第一次使用大模型时会下意识地把它当成一个“什么都能读的超级工具”。尤其是出现“上传 PDF 并提问”的功能之后大家默认模型已经能理解 PDF 内容了。但从技术实现来看绝大多数这类功能的后端流程并不是“模型直接读 PDF”而是“先用程序把 PDF 转成文本再把文本分块后处理”。LLM 本质上是基于 Token 的文本模型它没有“翻页”的能力也没有“查找目录”的能力。如果把整个 PDF 都塞进一次请求模型会面临上下文窗口大小的限制如果只塞一部分模型又不知道其他页里有什么关键信息。这种“看不到整体、没法自主定位”的状态就是我所说的“不能跳”——LLM 无法像人一样在文档中跳转、筛选和定位。明确这个概念之后很多诡异现象就能解释了为什么让模型总结一份 100 页的 PDF它只提到了前 20 页的内容因为模型看到的只是前 20 页对应的文本片段。为什么让它找某个具体数据它经常找不到因为那个数据可能在第 80 页而模型根本没有机会“跳过去”看。1.2 PDF 不是“文本文件”而是“版式文件”从文件格式角度看PDF 全称是 Portable Document Format翻译为“便携式文档格式”。它的设计目标是在不同设备上保持完全一致的视觉呈现效果。为了做到这一点PDF 内部存储的不是像 Word 或 Markdown 那样连续的文本流而是“每个字符放在页面哪个坐标位置”的描述。这意味着一段在视觉上看起来排版整齐的文字其底层数据可能被拆成很多碎片。比如一个跨越多栏的报纸页面阅读顺序在视觉上是从左栏到右栏但在 PDF 内部字符可能是按某种引擎优化的顺序存储的。如果直接抽取文本经常会得到逻辑顺序错乱的字符串。这种格式特性给 LLM 处理 PDF 带来了比处理纯文本大得多的难度。1.3 “不能跳”带来的三个实际影响从实际项目来看“LLMs Cant Jump”主要体现在三个方面长文档覆盖不全。只要文档超过模型可接受的有效阅读长度模型就无法访问全文回答往往基于局部信息。关键信息定位困难。模型没有“搜索”能力不会先找到关键段落再精读只能按顺序处理可见文本。多版式和复杂排版理解差。PDF 中的表格、页眉页脚、双栏论文、注释框等视觉结构抽成纯文本后会丢失布局信息模型更难理解内容关系。理解了这些限制我们再来看具体的解决思路。2. 核心难题拆解LLM 读 PDF 到底难在哪里2.1 PDF 文本抽取的混乱先看一个简单的例子。下面这段代码用 PyMuPDF即 fitz 库读取一个 PDF 文件并按页输出文本import fitz # PyMuPDF pdf_path example.pdf doc fitz.open(pdf_path) for page_num in range(len(doc)): page doc.load_page(page_num) text page.get_text(text) print(f--- Page {page_num 1} ---) print(text[:500])这段代码在处理排版简单的 PDF 时效果不错但遇到双栏论文时输出顺序往往是一会儿左栏、一会儿右栏甚至把左右两栏交叉拼接在一起。遇到页眉页脚时每一页的开头和结尾都会混入无关信息。遇到公式时公式会变成乱码或者完全丢失。遇到复杂表格时单元格内容和坐标信息会混在一起很难恢复原始表格结构。这些问题都属于“抽取层”的问题还没轮到模型理解内容文本就已经失真了。这也是为什么我强烈建议在生产环境里不要把 PDF 抽取的期望寄托在一个简单函数上而是要根据具体文档类型选择合适的抽取策略。2.2 上下文窗口的物理限制上下文窗口Context Window是 LLM 每次请求能接收的最大 Token 数量。虽然近两年主流模型的窗口已经从 4K、8K 扩展到 128K、200K 甚至更长看起来似乎可以把整本 PDF 塞进去但在工程实践中需要注意几点第一超长上下文的成本明显上升。Token 是按数量计费的每多一页 PDF 都意味着更多的输入成本。第二长上下文的注意力分布存在不均匀问题部分研究指出模型容易忽略处于中间位置的内容这被称为“Lost in the Middle”现象。第三把整个 PDF 放进单个请求后如果要继续追问客户端往往需要把完整的上下文再发送一次资源消耗成倍增加。所以即使某一天上下文窗口无限大了“把所有内容一股脑丢给模型”依然不是最优解。更合理的做法是让模型只关注与问题相关的少量片段。2.3 检索定位的缺失LLM 本身不具备“记忆检索”能力。当用户问“报告中第三季度的营收是多少”时模型需要从文档中找到对应的片段而不是靠猜测。要做到这一点必须把“找内容”这件事独立出来交给专门的检索模块。这就引出了 RAGRetrieval-Augmented Generation检索增强生成。RAG 的核心思路是先把文档拆成小块并向量化用户提问时先从向量库中检索最相关的若干块再把这些块和问题一起交给 LLM 生成回答。本质上这是用“检索”来模拟人的“跳跃阅读”——模型虽然自己不能跳但我们帮它把需要的那一页“搬”了过来。3. 环境准备与版本说明下面进入实战环节。为了让示例可运行你需要准备以下环境操作系统Windows / macOS / Linux 均可示例代码不涉及系统特定命令。Python 版本推荐 Python 3.10 或更高版本。主要依赖库PyMuPDF、pdfplumber、LangChain可选、FAISS可选。IDE 或命令行工具PyCharm、VS Code 或终端均可。以 pip 安装依赖为例pip install pymupdf pdfplumber langchain langchain-community faiss-cpu如果使用 HuggingFace Embedding 模型还需要安装pip install sentence-transformers这里需要说明的是LangChain 的 API 在 0.1.x、0.2.x、0.3.x 等版本之间变动较大不同版本的导入路径和类名可能不一致。如果你在跑代码时遇到 ModuleNotFoundError优先检查版本并调整导入路径。本文示例以常见语法的思路为主重点演示流程不追求绑定某个固定版本。4. 实战方案一文本抽取与分块在搭建 RAG 之前先把“从 PDF 到干净文本”这一步做好。这是整个链路的基座。4.1 按页读取 PDF 文本先使用 PyMuPDF 完成基础抽取。PyMuPDF 是一个基于 MuPDF 渲染引擎的 Python 库抽取速度和精度在同类型工具中表现不错。import fitz def extract_text_from_pdf(pdf_path: str) - list[str]: 按页抽取 PDF 文本返回每一页的文本列表 doc fitz.open(pdf_path) page_texts [] for page_num in range(len(doc)): page doc.load_page(page_num) text page.get_text(text) page_texts.append(text) doc.close() return page_texts if __name__ __main__: texts extract_text_from_pdf(example.pdf) print(f共抽取 {len(texts)} 页) for i, text in enumerate(texts[:3]): print(f\n Page {i 1} ) print(text[:300])get_text(text)返回的是纯文本模式适合大多数场景。如果排版非常复杂可以尝试get_text(blocks)或get_text(words)拿到包含坐标的信息块便于做预处理。4.2 清洗文本内容抽取出来的文本通常包含页眉页脚、参考文献编号、多余换行等问题。下面提供一个简单的清洗函数import re def clean_text(text: str) - str: # 去除连续空白字符统一为单个空格 text re.sub(r\s, , text) # 去除页码等常见页眉页脚格式例如 第 1 页 或 - 1 - text re.sub(r第\s*\d\s*页, , text) text re.sub(r-\s*\d\s*-, , text) # 清理多余空格 text text.strip() return text这个函数适合作为第一道防线。不同的项目文档有各自的页眉页脚形式建议先抽样检查几页文本再针对性地补充规则。4.3 分块策略抽取并清洗后的文本需要切成合适大小的块Chunk。分块大小会影响两个指标块太大时检索返回的内容不够聚焦块太小时语义不完整检索准确率也会受影响。常用的分块方式有两种固定字符分块并设置重叠区间。按标题或段落语义分块。使用 LangChain 的 RecursiveCharacterTextSplitter 可以方便地实现第二种方式from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , ., ], ) # 假设 full_text 是某页清洗后的完整文本 raw_docs [full_text] chunks text_splitter.split_text(full_text) for idx, chunk in enumerate(chunks[:5]): print(f--- chunk {idx} ---) print(chunk) print()chunk_size500表示每个块约 500 个字符chunk_overlap50表示相邻块之间有 50 个字符的重叠目的是避免语义被切断。separators指定了优先按段落和句子边界切分而不是死板地按字符数切。这里的重点是分块时要尽量保留章节或段落的完整性不要让一个观点被切到两个块里。后续做问答时分块质量往往直接决定回答质量。5. 实战方案二给 LLM 装上“跳跃”能力RAG5.1 RAG 基本流程RAG 的流程可以拆成四个阶段文档加载与清洗。文本分块。向量化并存入向量数据库。用户提问时检索相关块并交给 LLM 回答。用大白话说就是把 PDF 变成一本“带目录和索引的书”用户提问后不是把整本书递给 LLM而是先根据索引找到最相关的几页只把那几页递给 LLM。5.2 向量化与检索向量化的目标是把文本变成一组数字使得语义相近的文本在向量空间中距离较近。常用的 Embedding 模型包括 OpenAI 的 text-embedding-3-small、BAAI 的 bge 系列等。本文使用 HuggingFace Embedding 作为示例from langchain_community.document_loaders import PyPDFLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import FAISS # 1. 加载 PDF loader PyPDFLoader(example.pdf) documents loader.load() # 2. 分块 text_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, ) chunks text_splitter.split_documents(documents) # 3. 向量化 embeddings HuggingFaceEmbeddings( model_nameBAAI/bge-small-zh-v1.5, encode_kwargs{normalize_embeddings: True}, ) # 4. 构建向量库 vectorstore FAISS.from_documents(chunks, embeddings) # 5. 保存到本地 vectorstore.save_local(faiss_index)如果你的 PDF 是英文文档可以把model_name换成BAAI/bge-small-en-v1.5。第一次运行会下载模型文件建议在网络畅通时操作。之后查询的时候只需要加载向量库并执行相似度检索vectorstore FAISS.load_local( faiss_index, embeddings, allow_dangerous_deserializationTrue, # 仅在自己的可信数据上加载时使用 ) query 这份报告的核心结论是什么 docs vectorstore.similarity_search(query, k3) for i, doc in enumerate(docs): print(f--- 检索结果 {i 1} ---) print(doc.page_content) print()这里有一个安全提示allow_dangerous_deserializationTrue只应该在加载你自己生成的、可信的向量索引文件时使用不要使用来源不明的 FAISS 索引文件避免反序列化攻击风险。5.3 组装 Prompt检索到相关的文本块之后接下来把它们和用户问题一起组装成 Prompt。Prompt 的设计原则是明确告诉模型“只能依据给定资料回答”同时给模型一个“资料中没有时怎么办”的兜底选项。def build_prompt(query: str, context_docs: list[str]) - str: context \n\n.join(context_docs) prompt f请只根据以下资料回答问题。 资料 {context} 问题{query} 要求 1. 如果资料中包含答案请直接回答 2. 如果资料中没有答案请回复“资料中没有找到相关信息” 3. 不要编造资料中不存在的内容。 return prompt # 使用示例 context_docs [doc.page_content for doc in docs] prompt build_prompt(query, context_docs) print(prompt)这一步就是把普通 LLM API 调用变成“带检索能力”的 LLM 调用的关键。你可以将最终的 prompt 交给 OpenAI、通义千问、文心一言或本地部署的开源模型只要接口支持文本输入即可。5.4 完整串联下面给出一个完整的问答函数示例把检索和提示词组装放到一起def ask_with_rag( query: str, vectorstore, llm_func, k: int 3 ): llm_func 是一个接受 prompt 字符串并返回回答字符串的函数 docs vectorstore.similarity_search(query, kk) context_docs [doc.page_content for doc in docs] prompt build_prompt(query, context_docs) answer llm_func(prompt) return answerllm_func可以是任何大模型接口的封装。比如你用 OpenAI SDKfrom openai import OpenAI client OpenAI() def chat(prompt: str) - str: resp client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: prompt}], temperature0.2, ) return resp.choices[0].message.content这样原来“LLMs Cant Jump”的问题就被绕过了模型虽然不能自己跳去读第 80 页但检索模块已经在向量库里找到了第 80 页对应的片段并且只把那段内容交给了模型。从使用者视角看LLM 像是有了一种“准确定位并跳跃阅读”的能力。6. 实战方案三表格和多栏文档的处理思路6.1 表格抽取纯文本方式处理表格很容易丢失结构因为表格的行列关系在文本里表现得不明显。对于包含较多表格的 PDF建议使用 pdfplumber 直接抽取表格import pdfplumber def extract_tables_from_pdf(pdf_path: str): all_tables [] with pdfplumber.open(pdf_path) as pdf: for page_num, page in enumerate(pdf.pages): tables page.extract_tables() for table in tables: all_tables.append({ page: page_num 1, table: table, }) return all_tables tables extract_tables_from_pdf(example.pdf) for item in tables[:3]: print(f第 {item[page]} 页发现表格) for row in item[table][:5]: print(row)抽取出的table是一个二维列表每一行是列表中的一个元素。你可以把表格转成 Markdown 格式再写入分块def table_to_markdown(table: list[list[str]]) - str: lines [] for i, row in enumerate(table): cells [str(c or ) for c in row] line | | .join(cells) | lines.append(line) if i 0: # 添加表头分隔行 lines.append(| |.join([ --- ] * len(cells)) |) return \n.join(lines)6.2 多栏 PDF 的阅读顺序还原学术论文、报纸等常见双栏排版。用 pdfplumber 或 PyMuPDF 抽取时文本顺序容易左右穿插。一种朴素思路是利用单词坐标信息先按 y 坐标分组为“行”再按 x 坐标对每行排序。import fitz def extract_text_by_columns(pdf_path: str, page_num: int 0, line_height: float 20.0): doc fitz.open(pdf_path) page doc.load_page(page_num) words page.get_text(words) # (x0, y0, x1, y1, word, block_no, line_no, word_no) # 按 y0 坐标向上取整分组再在同一组内按 x0 排序 words_sorted sorted(words, keylambda w: (round(w[1] / line_height), w[0])) lines {} for w in words_sorted: row_key round(w[1] / line_height) lines.setdefault(row_key, []).append(w[4]) text_lines [ .join(lines[key]) for key in sorted(lines.keys())] doc.close() return \n.join(text_lines)这个思路只是简单示例。真实场景中的多栏情况可能更复杂小标题横跨两栏、插图穿插、栏目间距不规则等。对于高要求场景建议使用专业的版面分析工具如 LayoutParser、PaddleOCR 的版面分析模块来识别栏位和区域。6.3 扫描版 PDF 的 OCR 思路如果 PDF 本身是扫描件页面里没有文本层直接抽取文本会得到空字符串。此时需要先把页面渲染成图片再做 OCR。# 思路示例先渲染页面为图片再使用 OCR 工具识别 # 这里不绑定具体 OCR 库常见选择包括 PaddleOCR、Tesseract 等 # # import fitz # doc fitz.open(scan.pdf) # page doc.load_page(0) # pix page.get_pixmap(dpi200) # pix.save(page_0.png)渲染为图片后可以接入 PaddleOCR 或云端 OCR 服务。OCR 的准确性受扫描质量、字体、清晰度影响较大生产项目中建议先在小样本上验证效果再决定是否全量处理。7. 常见问题与排查思路问题现象常见原因解决思路抽取出的文本为空PDF 是扫描件没有文本层渲染页面图片使用 OCR 识别文本文本顺序错乱左右栏内容交叉双栏或多栏排版抽取时按内部顺序输出使用坐标信息排序或接入版面分析工具表格内容丢失或乱序纯文本抽取无法还原表格行列结构使用 pdfplumber 的 extract_tables() 提取表格转为 Markdown检索结果不相关Embedding 模型与文档语种不匹配或分块过大/过小选择对应语种的 Embedding 模型调整 chunk_size 与 overlap模型回答“资料中没有”但文档里确实有检索 k 值太小或者问题关键词与原文不一致增大 k 值对 query 做改写检查分块是否把关键内容切断FAISS 加载报错提示反序列化安全问题新版 LangChain 默认禁止加载未经验证的索引仅加载自己生成的索引文件时显式开启 allow_dangerous_deserialization处理超大 PDF 时内存占用过高一次性加载所有页和向量资源消耗大逐页读取、分批向量化并考虑用更高性能的向量数据库除了表格里列出的问题还有两个值得注意的常见坑。第一个是分块时切断了表格的完整结构。如果表格被切成上下两段检索时只拿到表头或只拿到数据行模型无法理解完整语义。建议把表格区域视为一个整体单元单独分块不与其他正文混在一起。第二个问题是检索到内容但模型被“无关片段”干扰。当 k 值设置过大时检索结果中混入的不相关内容会干扰模型判断。建议在 Prompt 中明确“资料中没有答案时不要根据无关信息推测”同时可以引入重排Rerank模型对检索结果做二次过滤。8. 最佳实践与工程建议8.1 先看数据再定方案在处理一个 PDF 知识库项目时不要急着写代码先抽 5 到 10 个有代表性的页面人工检查文本质量。文档是文字型还是扫描型有大量表格吗是多栏排版吗这些问题决定你后续选用哪些工具。数据情况不同方案差异很大。8.2 分块策略要按文档类型调优没有一种分块策略能适配所有文档。对于章节清晰的报告可以按标题层级分块对于连续性强的论文可以按固定长度加重叠分块对于表格密集的文档表格应该单独提取并单独分块。建议把分块逻辑做成可配置项方便后续调优。8.3 建立评估集RAG 效果好不好不能靠“感觉”。建议准备一套评测问答对每个问题有标准答案和对应的原文出处。每次修改分块参数或 Embedding 模型后用同一套评测集跑一遍对比召回率和回答准确率。这也是长期维护知识库机器人时最有价值的工作。8.4 注意模型幻觉与权限边界当检索结果中没有正确答案时LLM 可能仍会尝试“编”一个答案。Prompt 里必须明确限制模型只能在给定资料内作答。同时如果处理的 PDF 涉及业务数据或个人信息要做好权限控制哪些用户可以提问、哪些文档可以被检索、答案是否可以包含敏感原始内容都需要在设计阶段考虑。8.5 Web 系统中的 PDF 处理要注意安全问题如果你是在 Java/Spring Boot 项目中上传 PDF 并交给后续的 LLM 流程处理一定要防范恶意文件上传和 XSS 攻击。一方面要校验文件类型、大小、MIME 类型另一方面如果后续需要在前端通过 iframe 预览 PDF也要对文件名、内容做必要的安全过滤。PDF 解析本质上是处理来自外部的不可信数据务必使用维护活跃的解析库并及时更新版本。9. 总结与学习路线“LLMs Cant Jump”不是说模型能力不行而是说模型的工作机制决定了它无法像人一样在长文档中跳跃式定位内容。理解了这一点你就会明白为什么“直接把 PDF 丢给模型”不是可靠的做法也更容易理解 RAG、Agent、文档解析工具在整个 LLM 应用栈里的位置。本文从 PDF 格式的本质出发拆解了 LLM 处理 PDF 的三个核心难题文本抽取乱、上下文窗口有限、缺乏检索定位能力。随后给出了三个层次的实战方案用 PyMuPDF 和 pdfplumber 完成基础抽取与表格提取用分块策略整理文本结构再用向量检索组装 RAG 问答链路最终让模型在“看不到全文”的情况下依然能够回答“全文某处”的问题。下一步你可以继续深入学习的方向包括更精细的版面分析工具用于处理复杂多栏和扫描件重排模型用于提升检索准确率Agent 框架让模型具备多步检索和工具调用能力以及如何用向量数据库如 Milvus、pgvector、Elasticsearch替换本地 FAISS支撑更大规模的文档库。建议先从一个小型的 PDF 知识库开始边踩坑边总结逐步把链路打磨稳定。如果这篇文章对你有帮助可以收藏备用也欢迎在实践中验证这些方案后再根据你的文档类型做针对性调整。