文档天然是有结构的。一篇产品手册会分成多个章节,每个章节下有标题、正文段落、项目列表、注意事项等。这些层级关系本身携带着重要的语义信息——某段文字是某个标题下的具体说明,还是独立的通用条款,意义完全不同。但在 RAG 系统中,如果简单地把文档切成等长的文本块,这些结构信息就会丢失,检索精度也会大打折扣。
为什么需要保留层级结构
在将文档拆分成检索用的片段(chunk)时,片段通常很短,可能只有一两百个字。如果只保留纯文本,检索时可能出现两个问题:
- 语义漂移:一段文字脱离了它的标题和章节背景,仅凭自身内容难以准确判断它到底在回答什么。比如“最低配置要求:8GB 内存”这句话,如果不知道它属于哪个产品、哪个版本,检索时很容易和无关问题关联上。
- 检索召回不完整:用户提问常常会隐含层级信息。问“X200 的散热设计是怎样的?”时,理想情况是能同时召回“散热方案”小节下的几个段落,甚至包括该小节标题,从而给生成模型完整的上下文。
因此,在文档解析阶段就识别并保留层级结构,相当于给每个文本片段打上了“语义坐标”,让检索不再只看文字表面的相似度,还能利用内容所处的章节、条目关系来提升召回的相关性。
如何解析和保留层级结构
实际处理中,解析层次结构需要根据文档格式(PDF、Word、Markdown 等)采取不同策略,但核心思路是一致的:
- 提取结构元素
- 对于 PDF 或 Word 文档,使用支持结构提取的解析工具(如 PyMuPDF 的布局分析、python-docx 的样式识别),根据原生大纲、字体大小、粗体、编号等特征识别标题层级。
- 对于 Markdown 或 HTML 等标记文本,直接利用
#或<h1>等标记就能得到清晰的标题树。
- 构建文档树(或片段元数据)
将文档组织成一棵树状结构,每个节点可以是一个章节、一个小节或一个段落。举例来说:
第三章 产品规格
├── 3.1 物理参数
│ ├── 段落:尺寸:30×20×5 cm
│ └── 段落:重量:1.2 kg
└── 3.2 电气参数
├── 段落:输入电压 100-240V AC
└── 列表:- 最大功率 150W
- 待机功耗 0.5W
- 分段时注入层级元数据
在将文档切分成检索用的 chunk 时,可以把当前 chunk 所属的章节路径(面包屑)作为元数据附加进去。例如,上面的“输入电压”段落对应的 chunk 可以携带元数据:
{
"text": "输入电压 100-240V AC",
"metadata": {
"title": "电气参数",
"section_path": "第三章 > 产品规格 > 3.2 电气参数",
"page": 12
}
}
- 检索阶段利用层级信息
- 拼接上下文:在生成回答时,除了将检索到的 chunk 文本发给模型,还可以将其所属的节标题、列表上下文也一并附上,帮助模型理解该段属于哪个大主题。
- 聚合与重排序:如果用户问题明确指向某个章节,可以优先召回同一章节下的多个 chunk,避免从无关章节中抓取碎片信息。
- 作为检索权重因子:一些方案会在向量搜索中加入层级匹配信号,比如对于标题命中的片段给予更高权重,或直接维持一个结构化的关键词索引辅助检索。
一条真实的实践准则
并不是所有场景都需要最完整的层级保留,关键是要“保留下游用得上的结构”。实践中一条简洁的做法是:把每个 chunk 当作“带标题的段落”。即每个文本块的开头或关联元数据中包含它所属的最接近的标题。这样,即使 chunk 很小,模型仍然能通过标题了解上下文。
例如,将一个 FAQ 页面切分时,“退货时效:7 天无理由”这个问答对单独作为一个 chunk,但元数据中记录它来自“售后服务 > 退货政策”。用户问“退货政策有哪些?”时,系统不仅召回这个 chunk,还能因为它归属于“退货政策”的标题下而优先呈现,而且生成回答时也能自然地说“在退货政策中,规定 7 天无理由退货”,而不是干巴巴地只念出数字。
工具参考
实际开发中,常用的结构解析方法包括:
- Unstructured 库:支持多种格式,可输出带有层级(
Title、NarrativeText、ListItem等)的文档元素,方便后续处理。 - LangChain 的文档加载器:其中 PDF、Markdown 等加载器能够保留基础结构,配合自定义的切分策略实现层级关联。
- 自研解析器:对于特定模板的 PDF(如合同、说明书),可基于表格识别或版面分析抽取固定标题‑内容块,保证结构信息不丢失。
小结
层级结构保留并不是一个炫技的环节,而是为 RAG 系统“注入常识”的关键一步。它让散落的文本片段重新回到它们原本的语义位置,使检索不再只靠词频撞大运,而是真正理解内容从哪里来、讲的是什么。将这一步骤做好,往往比单纯优化向量模型或切块大小,更能带来检索准确率和答案可读性的明显提升。