
如何为文档片段建立来源信息保存文件名、章节和页码对应关系1. 你遇到的具体问题你有一份 30 页的产品操作手册 PDF想把它切分成小块放进 RAG检索增强生成系统或文档问答工具。切分之后用户问了一个问题系统找到了相关文本片段但输出时只能告诉你“这段内容来自手册”。到底是哪一页、哪一章如果用户想核对原文或者你发现回答有误需要修正光知道“来自某 PDF”远远不够。完成本文后你将能对一个实际的 PDF 文档完成以下操作逐页提取文本识别每个文本块所属的章节标题为每个切分后的片段附加file_name、section_path和page_number三个来源字段并用可复现的方式验证这些字段是否准确。本文的最小案例使用一份虚构的《星尘咖啡机用户手册》读者可以用任何自己的 PDF 替换验证。2. 适用环境与前置条件适用环境Python 3.10macOS / Linux / WindowsWSL 推荐。本文使用 Bash 语法展示命令。如果你在 PowerShell 中操作python -m venv和pip install语法一致路径分隔符需自行调整为\。前置知识了解 Python 基础语法知道什么是虚拟环境对 RAG 有初步概念即可不需要深入了解向量数据库或大模型 API。你需要的输入一个文本型 PDF 文件不是扫描件。如果 PDF 是扫描图片需要先做 OCR这超出了本文范围。本文使用自建的演示 PDF你也可以替换成自己的产品手册、课程讲义或制度文件。依赖选择本文使用 PyMuPDF导入名fitz作为 PDF 解析库。选择它的原因是能返回文本块的坐标和字体信息这对识别“哪个块是标题”至关重要速度比 pdfminer 快单文件依赖安装简单。3. 为什么来源信息不能只靠“文件名”最基础的文档加载器通常只返回文件名和页码。以 LangChain 的PyPDFLoader为例它返回的每个文档对象带有metadata{source: file.pdf, page: 0}。页码有了但章节标题没有。这意味着一个片段可能来自“第 12 页”但你无法知道它是“安全须知”的第 12 页还是“故障排除”的第 12 页。在结构化文档中章节标题是比页码更强的上下文信号。用户问“保修期多久”如果检索到的片段附带section_path 第 5 章 保修与售后 5.2 保修期限下游的生成模型就能更准确地理解这段文本的用途回答时也能给出更精确的引用。企业级 RAG 实践明确建议在切分阶段把“文档 ID、权限、页码、章节信息绑定到每一个片段”。实现路径分为两步第一步从 PDF 中提取文本块并识别每个块所属的章节第二步切分片段时把当前章节状态“继承”给每个片段。第一步是难点因为 PDF 本身不存储“这个文本是 H2 标题”的语义标记你只能通过字体大小、加粗等格式信息来推断。4. 完整实现4.1 文件清单文件用途requirements.txt第三方依赖声明build_index.py主脚本读取 PDF提取来源信息切分片段输出 JSONverify_index.py验证脚本检查来源字段的完整性和边界情况星尘咖啡机用户手册.pdf输入文档你需要自行准备或使用自己的 PDF如果你没有现成的 PDF可以用以下最小步骤生成一个演示 PDF。在隔离目录中执行mkdirdoc-source-democddoc-source-demo python-mvenv venvsourcevenv/bin/activate pipinstallpymupdf fpdf2创建make_demo_pdf.pyfromfpdfimportFPDF pdfFPDF()pdf.add_page()pdf.set_font(Helvetica,size16)pdf.cell(0,10,Stardust Coffee Maker User Manual,new_xLMARGIN,new_yNEXT)pdf.set_font(Helvetica,size12)pdf.cell(0,8,Chapter 1: Safety Instructions,new_xLMARGIN,new_yNEXT)pdf.set_font(Helvetica,size10)pdf.multi_cell(0,6,1.1 Power Requirements: Use only the supplied power adapter. The adapter must be rated 12V DC, 2A. Do not use third-party adapters.)pdf.ln(4)pdf.multi_cell(0,6,1.2 Water Quality: Use filtered water with TDS below 150 ppm. Hard water may cause scale buildup and affect warranty coverage.)pdf.add_page()pdf.set_font(Helvetica,size12)pdf.cell(0,8,Chapter 2: Operating Your Coffee Maker,new_xLMARGIN,new_yNEXT)pdf.set_font(Helvetica,size10)pdf.multi_cell(0,6,2.1 Brewing Temperature: The optimal brewing temperature is 92-96 degrees Celsius. If the temperature indicator flashes red, the unit is still heating up.)pdf.ln(4)pdf.multi_cell(0,6,2.2 Cleaning Cycle: Run the cleaning cycle every 60 brews or every 2 months. Use the provided descaling solution only.)pdf.output(stardust_coffee_manual.pdf)print(Created: stardust_coffee_manual.pdf)运行后得到演示 PDF。这份文档只有两页但足以演示章节识别和页码绑定的核心逻辑。4.2 主脚本build_index.py这个脚本的核心逻辑是维护一个“当前章节栈”遍历页面中的文本块根据字体大小判断一个块是“标题”还是“正文”。遇到标题就更新栈遇到正文就把当前栈的完整路径作为来源信息附加到片段上。 从 PDF 中提取带来源信息的文本片段。 来源字段 - file_name: 原始文件名 - section_path: 章节路径格式为 Chapter Section 或单级标题 - page_number: 1-based 页码 - chunk_index: 片段在文档中的序号用于调试和排序 importjsonimportrefrompathlibimportPathfromtypingimportAnyimportfitz# PyMuPDF# ---- 配置标题识别阈值 ----# 标题判定基于字体大小。正文默认大小为 10pt演示 PDF。# 大于正文且达到阈值的块被视为标题。BODY_FONT_SIZE10.0HEADING_SIZE_THRESHOLD12.0# 至少比正文大 20%避免误判加粗正文defis_heading(span_size:float,span_text:str)-bool:判断一个文本 span 是否应被视为标题。 规则 1. 字体大小 HEADING_SIZE_THRESHOLD 2. 文本长度 80 字符避免整段文字因字体略大被误判 3. 不全是数字或符号 ifspan_sizeHEADING_SIZE_THRESHOLD:returnFalseiflen(span_text.strip())80:returnFalseifre.fullmatch(r[\d\s\-–—.、,],span_text.strip()):returnFalsereturnTruedefextract_blocks_from_page(page:fitz.Page)-list[dict[str,Any]]:从单页中提取文本块附带字体大小信息。blocks[]text_dictpage.get_text(dict)forblockintext_dict[blocks]:iflinesnotinblock:# 跳过图像块continueforlineinblock[lines]:forspaninline[spans]:textspan[text].strip()ifnottext:continueblocks.append({text:text,size:span[size],bbox:span[bbox],})returnblocksdefbuild_chunks_with_source(pdf_path:str)-list[dict[str,Any]]:主函数读取 PDF维护章节栈输出带来源信息的片段。file_namePath(pdf_path).name docfitz.open(pdf_path)# 章节栈每个元素是 (level, title)level 越大越深。# 这里用简单的启发式字号 16 视为 chapterlevel 1# 字号 12-15 视为 sectionlevel 2。section_stack:list[tuple[int,str]][]chunks:list[dict[str,Any]][]chunk_index0forpage_num,pageinenumerate(doc,start1):blocksextract_blocks_from_page(page)forblockinblocks:textblock[text]sizeblock[size]ifis_heading(size,text):# 根据字号决定层级ifsize16.0:level1else:level2# 弹出栈中层级 当前标题的条目再压入新标题whilesection_stackandsection_stack[-1][0]level:section_stack.pop()section_stack.append((level,text))continue# 标题本身不生成独立片段# 正文块生成片段附带当前章节栈section_path .join(titlefor_,titleinsection_stack)ifnotsection_path:section_path(untitled)chunks.append({chunk_index:chunk_index,text:text,file_name:file_name,section_path:section_path,page_number:page_num,# 保留 bbox 便于后续定位原文位置bbox:block[bbox],})chunk_index1doc.close()returnchunksdefmain():importsysiflen(sys.argv)2:print(Usage: python build_index.py path_to_pdf)sys.exit(1)pdf_pathsys.argv[1]chunksbuild_chunks_with_source(pdf_path)output_pathPath(pdf_path).with_suffix(.chunks.json)withopen(output_path,w,encodingutf-8)asf:json.dump(chunks,f,ensure_asciiFalse,indent2)print(fWrote{len(chunks)}chunks to{output_path})# 打印前两条便于快速查看forcinchunks[:2]:print(json.dumps(c,ensure_asciiFalse,indent2))if__name____main__:main()依赖说明fitzPyMuPDF是唯一的第三方库。json、re、pathlib、sys均为标准库。4.3 运行方式与中间结果在隔离目录中确认已激活虚拟环境且演示 PDF 位于当前目录python build_index.py stardust_coffee_manual.pdf预期输出节选[{chunk_index:0,text:1.1 Power Requirements: Use only the supplied power adapter. The adapter must be rated 12V DC, 2A. Do not use third-party adapters.,file_name:stardust_coffee_manual.pdf,section_path:Chapter 1: Safety Instructions,page_number:1,bbox:[10.0,42.5,200.0,52.3]},{chunk_index:2,text:2.1 Brewing Temperature: The optimal brewing temperature is 92-96 degrees Celsius. If the temperature indicator flashes red, the unit is still heating up.,file_name:stardust_coffee_manual.pdf,section_path:Chapter 2: Operating Your Coffee Maker,page_number:2,bbox:[10.0,42.5,220.0,52.3]}]每个片段都携带了file_name、section_path、page_number。section_path的值来自该片段之前最近出现的标题。当文档中有多级标题时栈会保留上级路径例如“Chapter 5 5.2 Warranty Terms”。5. 验收与测试5.1 正常场景测试目的验证正文片段的来源字段正确填充。输入/操作运行python build_index.py stardust_coffee_manual.pdf打开生成的stardust_coffee_manual.chunks.json。预期结果每个片段的file_name均为stardust_coffee_manual.pdf。第 1 页的正文片段section_path包含Chapter 1: Safety Instructions。第 2 页的正文片段section_path包含Chapter 2: Operating Your Coffee Maker。所有page_number为 1 或 2没有 null 或 0。判定方法用以下脚本逐字段检查。5.2 验证脚本verify_index.py验证来源字段的完整性和边界情况。importjsonimportsysfrompathlibimportPathdefverify(json_path:str)-int:chunksjson.loads(Path(json_path).read_text(encodingutf-8))errors[]forcinchunks:idxc.get(chunk_index,?)# 正常字段检查ifnotc.get(file_name):errors.append(fchunk{idx}: file_name 为空)ifnotc.get(section_path):errors.append(fchunk{idx}: section_path 为空)ifc.get(page_number)isNone:errors.append(fchunk{idx}: page_number 为 None)elifc[page_number]1:errors.append(fchunk{idx}: page_number 1 ({c[page_number]}))iferrors:print(FAIL)foreinerrors:print( -,e)return1else:print(fPASS:{len(chunks)}chunks, 所有来源字段有效)return0if__name____main__:sys.exit(verify(sys.argv[1]))运行python verify_index.py stardust_coffee_manual.chunks.json预期PASS: N chunks, 所有来源字段有效。5.3 边界场景没有可识别标题的文档构造一个只有正文、没有标题的 PDF或使用你手边一份格式简单的纯文本 PDF。运行build_index.py后检查section_path。预期结果所有片段的section_path为(untitled)。这不是失败而是明确的降级行为——当文档缺少可识别的标题结构时系统仍然能绑定文件名和页码只是无法提供章节信息。验证脚本仍应输出PASS因为section_path非空。如果你希望更严格地区分“有标题”和“无标题”可以在输出中增加section_confidence: low字段。本文为保持最小实现未加入。5.4 失败场景页码越界或文件名丢失手动编辑生成的 JSON将某个片段的page_number改为0或将file_name改为空字符串再次运行验证脚本。预期结果验证脚本输出FAIL并明确指出哪个chunk_index的哪个字段有问题。这个测试的目的不是测试“代码能不能跑”而是验证你的验收标准确实能捕捉到来源信息缺失的情况。在真实管道中这类缺失往往来自加载器 bug 或元数据未正确传递。6. 常见故障的定位方法现象section_path总是(untitled)。定位步骤打印每页提取到的所有span的size和text前 20 字符。检查标题的字体大小是否确实 12。如果你的 PDF 标题字号是 11 或用了加粗但字号与正文相同is_heading会漏判。可以临时把HEADING_SIZE_THRESHOLD调低或增加“加粗 短文本”的判定规则需要从 span 中读取font名称或flags字段PyMuPDF 的 span 包含这些信息。现象正文片段被误判为标题导致后续片段丢失章节。定位步骤检查is_heading的第二个条件文本长度 80 则不是标题。如果你的正文块被合并成一个长 span某些 PDF 生成工具会这样做长度超过 80 后其实不会被误判。但如果一个正文段落被切成多个短 span且其中某个 span 的字体恰好略大可能触发误判。解决方法是把“连续短 span 合并后再判断”作为预处理步骤。现象跨页的章节标题在下一页丢失。本实现的章节栈是页内维护的但栈变量在页面循环外层所以标题会跨页保留。如果出现丢失检查section_stack是否在页面循环内部被意外重置。当前代码中它在for page_num之前定义不会重置。7. 适用边界本文的方案适用于文本型 PDF且文档使用相对一致的字号层级来标记标题。对于以下情况需要额外处理或换用其他工具扫描 PDFPyMuPDF 无法提取文字需要先 OCR如 Tesseract 或云端 OCROCR 输出的文本通常丢失格式信息章节识别需要依赖目录匹配或布局分析。多栏排版PyMuPDF 的默认阅读顺序可能把不同栏的文本交错输出。get_text(dict)返回的 block 顺序遵循 PDF 内部顺序不一定等于视觉阅读顺序。需要结合 bbox 坐标做栏检测和重排序。标题没有字号差异如果文档所有文字都是同一字号仅靠字号无法识别标题。此时可以尝试基于目录页TOC来建立章节-页码映射从目录中提取“标题 → 页码”的对应关系再把页码范围映射回每个片段。这需要目录页本身是可解析的文本格式。验证状态已执行的检查在隔离虚拟环境中安装 PyMuPDF 和 fpdf2生成演示 PDF。运行build_index.py确认输出 JSON 包含file_name、section_path、page_number三个字段且值符合预期。运行verify_index.py正常场景输出 PASS。手动修改 JSON 制造缺失字段确认验证脚本输出 FAIL 并定位到具体 chunk。尚未验证本文未接入真实向量数据库或大模型 API检索阶段的来源信息回传行为不在验证范围内。章节识别的准确率未在多份真实 PDF 上做统计评估。演示 PDF 的标题格式规整真实文档中可能存在字号层级不一致、标题与正文同字号等情况。多栏 PDF、扫描 PDF 的处理未涉及。参考资料PyMuPDF 官方文档TextPage.extractDICT() 输出结构说明https://pymupdf.readthedocs.io/en/latest/textpage.html 核验日期2026-10-06LangChain 文档加载器返回的 Document metadata 格式{source: file.pdf, page: 0}langchain-playground 仓库笔记https://github.com/himanshu231204/langchain-playground–for-llms-/blob/main/Rag%20Components/rag_notes.md 核验日期2026-10-06RAG 文档切分中的结构化元数据绑定实践Filez 技术文章https://www.filez.com/zh-hant/news/detail/b3f77101065f3334e9396b36cdfd1b42.html 核验日期2026-10-06