对于 RAG,如果希望分块遵循文档的章节结构,请将 PDF 转为 Markdown:每个标题都是一个拆分点,分块上方的各级标题会作为元数据随分块一起保存。建立索引之前,请检查标题、表格和 OCR 文字,然后用你已知答案的问题测试检索效果。
本指南面向在私有文档上构建检索的开发者:制度、手册、合同、研究资料。它假定你已经选定了嵌入模型和向量库。
为什么按标题分块效果更好
固定大小的拆分器会把文字切成固定长度的片段,切在哪里就是哪里。一个分块可能从某一节中间开始,丢掉说明它讲什么的标题。“请求限制为每分钟 100 次”在“Sandbox”下和在“Production”下含义不同。没有标题,检索器和模型都无法分辨是哪一种。
Markdown 以 #、## 和 ### 开头的行来保留文档的大纲。基于标题的拆分器在这些行处切分,所以每个分块只覆盖一个章节,并记录分块上方的各级标题:即它的章节路径。 LangChain 的 Markdown 拆分指南 介绍了同样的思路:让文字与它所属的标题保持在一起。
各章节长短不一,所以较长的章节仍需要再按大小拆分一次。章节路径适用于它的每一个片段。
转换 PDF 并检查标题
在 MD Factory 中,添加装有 PDF 的文件夹。每个 Markdown 文件都以相同的名称保存在对应 PDF 旁边,图片放在旁边的文件夹里。扫描版 PDF 会在你的电脑上进行 OCR。文件夹中的 Word 文件和其他格式也以同样方式转换。
在编写任何拆分代码之前,先打开几个结果看看:
- 确认章、节和小节分别输出为 #、## 和 ###。PDF 往往只用更大或更粗的字体来标示标题,所以任何转换器都必须推断各节从哪里开始以及如何嵌套。请修正层级有误的地方。
- 在 Word 文件中,如果作者使用了 Word 的标题样式,标题会保留。仅仅加粗或字号较大的文字仍是普通段落。
- 留意文字中间是否混入了页眉、页脚和页码。
- 点击文件旁边的需要检查,查看要检查什么。对于 PDF,列表中可能包括 OCR 无法读取部分文字或完全没有找到文字的页面。
建立索引之前,请先修正 Markdown 文件中的问题。每个分块都会继承这些问题。
在代码中按标题拆分
LangChain 的 MarkdownHeaderTextSplitter 会按你列出的标题级别拆分,并把每个标题放入分块的元数据中。再用第二个拆分器限制每个章节内部的大小:
from pathlib import Path
from langchain_text_splitters import (
MarkdownHeaderTextSplitter,
RecursiveCharacterTextSplitter,
)
corpus = Path("corpus")
by_heading = MarkdownHeaderTextSplitter(
[("#", "h1"), ("##", "h2"), ("###", "h3")], strip_headers=False
)
by_size = RecursiveCharacterTextSplitter(chunk_size=1500, chunk_overlap=150)
chunks = []
for path in sorted(corpus.rglob("*.md")):
sections = by_heading.split_text(path.read_text(encoding="utf-8"))
for chunk in by_size.split_documents(sections):
chunk.metadata["source"] = path.relative_to(corpus).as_posix()
chunk.metadata["section"] = " > ".join(
chunk.metadata[h] for h in ("h1", "h2", "h3") if h in chunk.metadata
)
chunks.append(chunk)
这里的分块大小和重叠只是起点,请根据你的嵌入模型调整。为了让标题行保留在分块文字中,使你嵌入的文字也写明它所属的章节,示例设置了 strip_headers=False.
LlamaIndex 的 MarkdownNodeParser 也做同样的按标题拆分。用 SimpleDirectoryReader 加载文件时,每个节点还会带上文件名:
from llama_index.core import SimpleDirectoryReader
from llama_index.core.node_parser import MarkdownNodeParser
documents = SimpleDirectoryReader(
"corpus", recursive=True, required_exts=[".md"]
).load_data()
nodes = MarkdownNodeParser().get_nodes_from_documents(documents)
print(nodes[0].metadata["file_name"], nodes[0].metadata["header_path"])
header_path 列出了节点上方的各级标题,例如 /Employee handbook/Expenses/;节点自己的标题是其文字的第一行。
让每个分块都带着来源
每个分块至少要存储三样信息:
- 文件。Markdown 文件与其 PDF 同名,所以来自 handbook.md 的分块可以追溯到 handbook.pdf。请存储相对于文档集合文件夹的路径,因为不同文件夹中两个都叫 terms.md 的文件是不同的文档。
- 章节路径,这样回答可以指明所用的章节,读者也能直接找到相应段落。
- 版本或日期。当某项制度变更时,应删除旧文件的分块并为新文件建立索引,而不是同时提供两个版本。
如果访问权限不同的人会查询同一个索引,还要存储谁可以读取每份文档,并在查询时据此过滤。检索器会返回所有与问题匹配的分块。
MD Factory 生成的 Markdown 没有页码。如果回答必须引用页码,请到原始 PDF 中查找该段落。
谨慎处理表格和 OCR 文字
Markdown 表格的表头行只在顶部出现一次。当表格比你的分块大小更长时,按大小拆分的拆分器会把它切开,后面的片段就没有列名了。在我们用 LangChain(langchain-text-splitters 1.1.3)所做的测试中,一个 40 行的表格按 300 个字符拆分后得到四个片段,只有第一个包含表头行。
有两种解决办法:即使比其他分块更长,也让每个表格保持在一个分块中;或者在每个片段的开头重复表头行。
对于 Word 和 Excel 文件,当合并或复杂的单元格被简化时,MD Factory 会将文档标记为需要检查。PDF 表格没有这样的标记,所以请将其中几个与原件对照,尤其是带合并表头的表格。
OCR 文字需要单独检查。读错的数字、名称和代码会原样进入回答,而分块中看不出任何异常。请将几个分块中的数字与扫描件对照,并在修正之前,把被标记的页面排除在索引之外。需要留意什么,请参阅我们的 扫描版 PDF 的 OCR 检查指南.
用真实问题测试检索
写下人们实际会问的问题,并为每个问题标出包含答案的文件和章节。先从十几二十个开始。
- 让每个问题都通过你的检索器,查看排名靠前的结果。
- 记下正确的章节是否在其中。
- 对于每次未命中,将返回的分块与本应返回的分块进行对比。常见原因:标题级别错误、表格与表头被切开、关键术语中有 OCR 错误,或者某一节太长,答案被淹没其中。
- 每次只改动一处,例如分块大小、标题级别或表格的拆分方式,然后用同样的问题再运行一遍。
把这些问题保存在文档集合旁边的文件里,每当文档或拆分代码发生变化时就重新运行。
何时使用 MD Factory,何时使用库
MD Factory 是一款 Windows 桌面应用。它适合你想在自己的电脑上转换并目视检查的私有文档集合:添加文件夹,查看状态,打开需要检查的文件,修正它们,然后让你的索引脚本指向该文件夹。
MD Factory 在转换文档时会把文档保留在你的电脑上。如果你随后用托管的嵌入 API 对分块做嵌入,或将它们发送给托管模型,其文字就会发送给该提供方。如果文档不得离开你的网络,请使用本地模型。
如果文档不断到来、位于服务器上,或者必须在无人值守的情况下转换,请改用代码中的转换库。有哪些选择,请参阅我们的 PDF 转 Markdown 转换器对比.