主题
11.2 数据源又脏又乱怎么办?—— 文档解析、清洗与切块
痛点场景:真实资料通常不够规整:扫描版 PDF 可能双栏错乱,Word 中混有表格,网页导出带着广告,页眉、页脚和水印还会反复出现。如果不先清理就写入向量库,检索和回答质量都会受到影响。 本节我们用「痛点 → 思路 → 代码实现 → 拓展」四段式,把“数据准备层”彻底打通。
痛点:垃圾进,垃圾出(Garbage In, Garbage Out)
先看一组真实的“车祸现场”:
| 数据源形态 | 典型脏点 | 后果 |
|---|---|---|
| 扫描 PDF | 缺字、乱码、双栏混读、需要 OCR | 检索到一堆“乱码碎片”,完全没法用 |
| 带表格的 Word/Excel | 表格被拆行、表头丢失 | 大模型看错列,答错数据 |
| 网页导出的 HTML | 导航、广告、版权声明混入正文 | 语义向量被噪声稀释 |
| 内部旧文档 | 页眉页脚、“第 38 页”、水印 | 相同噪声反复入库,污染检索结果 |
为什么解析这一步决定了整个 RAG 的上限? 因为后面所有环节(切块、嵌入、检索、生成)都是在“已解析出的文本”上进行的。这一步出错,后面再高级的技术也救不回来。
思路:三步走,把“脏数据”变成“干净 Chunk”
第 1 步:解析(Parsing)—— 把二进制文件变成文字。核心是“按格式选对工具”:
| 文件格式 | 轻量方案 | 重量级/高保真方案 |
|---|---|---|
pypdf、pymupdf(fitz) | MinerU、Marker(还原公式/多栏/表格为 Markdown) | |
| Word | python-docx、docx2txt | Unstructured(统一几十种格式) |
| HTML | BeautifulSoup | unstructured 的 partition_html |
| Markdown | 直接读文本 | MarkdownHeaderTextSplitter 保留标题层级 |
第 2 步:清洗(Cleaning)—— 去掉噪音、归一化文本。常见的清洗操作:
- 删除页眉页脚/水印(“第 38 页”、“机密文件严禁外传”);
- 归一化空白(多个空格、
\n混乱、全角半角不统一); - 去掉 HTML 标签、脚本、导航广告;
- 把表格按“行 × 列”转成可读的文本段落(保留表头语义)。
清洗最容易踩的坑,不是“没洗干净”,而是洗过了头。页码和水印当然该删,但 testdata 里的制度是按 ## 第 1 页:年假资格 这种标题分页的——标题里也有“第 1 页”。正则如果写成“看见「第 N 页」就全删”,等于把目录撕掉再让人找书。后面切块、引用、评估全靠这些页标题对上原文,所以规则必须写成:只删单独成行的页眉页脚,标题里的页码留下。
第 3 步:切块(Chunking)—— 把长文拆成适合检索和模型输入的小块,并附上元数据。这一步决定了检索精度与上下文完整性之间的平衡。
场景:我们要处理的“真实脏文档”
办公室里的制度很少是一份干干净净的 Markdown。财务把《差旅管理制度》导出成 PDF 发全员邮箱,报销说明和废止旧版还停在 Word 里改了三版,行政服务台又从供应商网站另存了一份带广告的 HTML。testdata/ 里的 8 份练习文档就是按这个现实排的:md / pdf / docx / html 混着来,一共 86 个页级 Chunk,每份 8 到 14 页不等。现行差旅制度和 RX-9000 手册是文字层 PDF,报销操作说明和废止旧版是 Word,外部注入样本是网页快照,协作手册、HR 制度和办公设备手册仍是 Markdown——检索前必须先按后缀选解析器,抽出的正文里仍然留着 ## 第 N 页 这种标题,后面切块、引用、评估才能对上同一套页 ID。
一线城市住宿上限 500 元、餐饮补贴 150 元、返回后 5 个工作日提交,这些金标数字写在 PDF 和 Word 里,不写在“假设已经解析好了”的注释里。扫描件仍然不要硬抽:抽出来是空的就报错,换 MinerU,不要把空白块写进向量库。
代码实现:从“脏文件”到“干净 Chunk”的完整管道
下面的代码直接复用了 LangChain 生态,但在三处做了比“默认用法”更工程化的处理:按后缀自动选解析器、正则清洗去噪、切块时统计质量并注入元数据。
python
# 11.2 数据准备管道:解析 → 清洗 → 切块 → 元数据
import re
from pathlib import Path
from typing import List
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
def load_pdf(path: Path) -> str:
"""文字层 PDF 用 pymupdf 抽文本。扫描件/双栏/公式见下文 MinerU 4.x。"""
import pymupdf
with pymupdf.open(path) as doc:
text = "\n".join(page.get_text("text") for page in doc).strip()
if not text:
raise ValueError(f"{path.name} 抽出来是空的,多半是扫描件,请改用 MinerU")
return text
def load_docx(path: Path) -> str:
"""Word 按段落拼回纯文本。教学语料把「## 第 N 页」写成普通段落。"""
from docx import Document as DocxDocument
return "\n".join(p.text for p in DocxDocument(str(path)).paragraphs)
def load_html(path: Path) -> str:
"""剥标签时在块级标签后补换行,避免页标题和正文粘成一行。"""
html = path.read_text(encoding="utf-8")
html = re.sub(r"(?i)<br\s*/?>", "\n", html)
html = re.sub(r"(?i)</(p|h1|h2|h3|div|article)>", "\n", html)
return re.sub(r"<[^>]+>", "", html)
# ============ 1. 解析:按文件后缀自动选择解析器 ============
def load_text_by_ext(path: Path) -> str:
"""按后缀把文件变成统一纯文本。PDF 默认走 pymupdf;扫描件请开 MinerU。"""
suffix = path.suffix.lower()
if suffix in {".md", ".txt"}:
return path.read_text(encoding="utf-8")
if suffix == ".html":
return load_html(path)
if suffix == ".pdf":
return load_pdf(path) # 完整双通道(含可选 MinerU)见 s02_data_pipeline.py
if suffix == ".docx":
return load_docx(path)
raise NotImplementedError(f"暂未实现 {suffix} 的解析器,教学管道目前覆盖 md/txt/html/docx/pdf")
# ============ 2. 清洗:去噪 + 归一化 ============
NOISE_PATTERNS = [
r"(?m)^第\s*\d+\s*页\s*$", # 只删单独成行的页眉,例如「第 38 页」
r"机密文件[,,]?严禁外传", # 水印/免责声明
r"\bPage\s+\d+\b", # 英文分页
r"^\s*[-–—]\s*\d+\s*[-–—]\s*$", # 单独成行的页码
]
def clean_text(raw: str) -> str:
text = raw
for pat in NOISE_PATTERNS:
text = re.sub(pat, "", text)
# 归一化空白:多个空格/换行压成一个空格
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
# 全角空格等不可见字符
text = text.replace("\u3000", " ")
return text.strip()
# ============ 3. 切块:递归字符切块(中文场景) ============
def make_splitter(chunk_size: int = 400, overlap: int = 60) -> RecursiveCharacterTextSplitter:
"""中文文档建议把 '。!?;' 加进分隔符,优先在句边界下刀,避免腰斩句子。"""
return RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=overlap,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
length_function=len,
)
# ============ 4. 组装完整管道 ============
def build_chunks(path: Path, source_name: str, category: str) -> List[Document]:
raw = load_text_by_ext(path)
cleaned = clean_text(raw)
splitter = make_splitter()
chunks = splitter.split_text(cleaned)
return [
Document(
page_content=chunk,
metadata={"source": source_name, "category": category, "chunk_index": i, "total": len(chunks)},
)
for i, chunk in enumerate(chunks)
]
> 上面这条是**基线**:纯按分隔符递归切,不了解文档结构。它是"能跑"的起点,
> 但制度文件里「第几条」这种结构被白扔了——后面那节
> [把上面两条落成代码](#把上面两条落成代码解析先读懂切块先分层)会把它升级成
> "按标题分节 + 表格整段保护"的版本。
# ============ 5. 运行验证 ============
if __name__ == "__main__":
demo_doc = Path(__file__).with_name("_demo_dirty.md") # 临时脏样本,跑完删掉
demo_doc.write_text(
"第 1 页\n"
"【员工差旅管理制度】\n"
"第一条 本制度适用于全体正式员工。\n"
"第二条 一线城市住宿标准为每人每天不超过 500 元;二线城市不超过 350 元。\n"
"第三条 出差餐饮补贴为每人每天 150 元,无需发票。\n"
"机密文件,严禁外传\n"
"第 2 页\n"
"第四条 差旅报销单须在返回工作地后 5 个工作日内提交 OA 系统。\n",
encoding="utf-8",
)
docs = build_chunks(demo_doc, "差旅制度_2026.md", "行政")
print(f"共生成 {len(docs)} 个 Chunk")
for d in docs:
print(f"\n[chunk {d.metadata['chunk_index']}/{d.metadata['total']}] source={d.metadata['source']}")
print(d.page_content)
demo_doc.unlink(missing_ok=True)这段代码与“随手一写”的 Demo 最大的不同在于:
- 清洗是显式的一步,而不是直接拿脏文本切块。上面那份样本里,“第 1 页”“机密文件严禁外传”会被清掉,正文条款留下;
- 元数据注入了
source / category / chunk_index,后面 11.5 才能按分类过滤,11.9 才能把答错的那一刀追到某一页; - 分隔符针对中文定制,优先在句号、问号、分号处下刀,少把“住宿上限 500 元”腰斩成两截。
演示跑完会删掉 _demo_dirty.md。仓库里真正拿来检索的,是 testdata/ 那 8 份混排制度(86 页):协作手册和办公设备手册仍是 Markdown,现行差旅和 RX-9000 手册是文字层 PDF,报销说明与废止旧版是 Word,外部注入样本是 HTML。shared_corpus 先按后缀解析,再按 ## 第 N 页 切成页级 Chunk,页 ID 不跟文件后缀走。
跑 python s02_data_pipeline.py 时,脚本会解析现行差旅 PDF:抽出住宿 500 元、餐饮 150 元,再切块。完整的 load_pdf() / load_docx() / write_digital_pdf() / try_mineru_markdown() 都在 code/s02_data_pipeline.py 里。依赖里有 pymupdf 和 python-docx;MinerU 仍然是可选开关,不写进 requirements.txt。
切块验收要回到原文核对
切块完成后,随机抽查正文、表格、列表和跨页段落。每个片段至少保留文档标识、版本、标题路径、页码或段落位置;否则检索命中后很难定位原文,也无法在文档更新时精确替换。
固定长度只是起点。制度条款应尽量保留完整条目,操作手册要让步骤与前置条件同处一块,表格则要保留表头。可以准备十个需要跨段理解的问题,用它们比较不同切块方式,而不是只看平均块长。
把上面两条落成代码:解析先读懂、切块先分层
「每个片段保留标题路径」「表格保留表头」这两条不是口号,它们各自对应一段必须写的代码。 下面是 KnowledgeForge_lite 里的真实实现(forge_lite/data/ingest.py + forge_lite/core/chunking.py), 每一处都对应一个不加就会踩的坑:
| 环节 | 做了什么 | 不做会怎样 |
|---|---|---|
| 编码 | UTF-8 → GB18030 两档试 | 一份 GBK 老制度直接抛异常,整篇进不了库 |
| Word 表格 | 按文档原顺序遍历段落和表格,表格转成 表格行: A | B | C | 表格整块消失——正文读起来还通顺,只是关键数字永远查不到 |
| HTML 脚本 | script / style / svg / head 整块删 | 变量名会被 BM25 当关键词召回 |
| 控制字符 | 清洗第一步就删(在归一化空白之前) | 隐含字符夹在词中间,同一句话的哈希对不上,重传一次就判成"内容变了" |
| 未填模板 | 识别 ×××公司 / 待定 并标记,不删 | 一份没填的模板混进索引,谁问都召回它,而它什么信息都没有 |
Word 那份要特别说一句:document.paragraphs 只收段落,表格整块消失。得顺着 XML body 逐个孩子走,才能拿到「段落、表格、段落」的真实顺序:
python
def _docx_blocks(document):
"""按文档原始顺序产出段落与表格。"""
from docx.oxml.table import CT_Tbl
from docx.oxml.text.paragraph import CT_P
for child in document.element.body.iterchildren():
if isinstance(child, CT_P):
yield "p", Paragraph(child, document)
elif isinstance(child, CT_Tbl):
yield "t", Table(child, document)切块这一侧,做法是先按标题分节,再在节内下刀。制度文件天然有层级,标题识别同时认 Markdown 的 # 和中文那套编号——真实文档大多不是 Markdown,Word 转出来就是下面这样:
python
# 深度递增,两套写法可以混用
_HEADING_CN = (
(1, re.compile(r"^(第[一二三四五六七八九十百零〇]+[章节])\s*(.*)$")), # 第二章
(2, re.compile(r"^(第[一二三四五六七八九十百零〇]+条)\s*(.*)$")), # 第一条
(3, re.compile(r"^([一二三四五六七八九十]+、)\s*(.*)$")), # 一、
(4, re.compile(r"^(([一二三四五六七八九十]+))\s*(.*)$")), # (一)
)分完节,这一节的标题路径就拼在每块前面:
《员工差旅管理制度》 › 第一条 住宿标准
## 第一条 住宿标准
- 一线城市住宿标准为每人每天不超过 500 元;《文件名》 给"这是哪一篇",› 第一条 住宿标准 给"这篇的哪一条"。两者都是切块脱离 原文后最容易丢的线索,而补它们零成本——不调模型,就是几行字符串拼接。这就是 11.2 后面讲的"迟切块"的退化路径:迟切块用模型给每块补上下文,这里用文档自己的结构补。
表格是不可分割的最小单位。 一行表格本来是一条完整记录(「一线城市 | 500 元」), 被打散到两块里,"500"就和它的城市名分了家——检索命中了也看不出这是哪个档位。 所以相邻的表格行会合成一整段,超长表格只在行与行之间下刀,且每块都补上表头:
python
def _split_table(rows, chunk_size):
"""把一张表按行切成若干块,每块都带上表头。"""
header = rows[0]
blocks, current, size = [], [], len(header)
for row in rows[1:]:
if current and size + len(row) + 1 > chunk_size:
blocks.append("\n".join([header, *current])) # 表头复制进每一块
current, size = [], len(header)
current.append(row)
size += len(row) + 1
if current:
blocks.append("\n".join([header, *current]))
return blocks顺序上的一个坑:装箱和编排,谁先谁后
这一段值得单独讲,因为它是只有跑评测才会发现的那类 bug。
检索拿到候选后要做两件事:装箱(按上下文预算决定哪些块进得去)和编排 (把最相关的放开头、第二相关的放结尾,对抗 lost-in-the-middle)。两件事单独看都对, 但顺序写反了就会出事:
python
# ❌ 先编排再装箱:第二名被特意挪到列表最末,而装箱按预算从尾部截断
ordered_keys = order_contexts(scored)
packed = _pack_hits(ordered_keys, max_chars) # 预算一紧,第一个被砍的正是那个第二名
# ✅ 先装箱再编排:谁进得来由相关度决定,进来之后怎么摆才是编排的事
packed = _pack_hits(by_relevance, max_chars)
final = order_contexts(packed)「越重要的越先被丢」听起来荒谬,但它就是这么发生的:order_contexts 把第二名放到末尾 是为了让模型读得到它,而装箱从末尾截断是为了省预算——两个正确设计的交集是一个错误行为。
这个 bug 是在 KnowledgeForge_lite 改完结构化切块之后被评测抓到的:切块变细、每块带上 标题路径,总长度变大,1800 字的预算覆盖不到最后几块了,于是"第二名"第一次被真的截掉, 通过率从 8/8 掉到 6/8。结构改动会改变预算的紧张程度,从而让潜伏的顺序 bug 浮出水面—— 这也是"改完要跑评测"比"改完看着没问题"多值的那部分。
同一篇文档,两种切法:一个能看懂的对照
光说"标题层级切块保留结构"还是抽象的。拿 Lite 里那份《员工差旅管理制度》看, 同一份原文用两种切法出来是这样:
① 固定/递归字符切(400 字一刀,不看结构)
┌────────────────────────────────────────────────┐
│ 第 0 块(412 字) │
│ 《员工差旅管理制度》 │
│ # 员工差旅管理制度(2026 修订版) │
│ > 适用范围:全体正式员工… │
│ ## 第一条 住宿标准 │
│ - 一线城市…不超过 500 元; │
│ - 二线城市…不超过 350 元; │
│ ## 第二条 餐饮与交通补贴 ← 第二条被切进来一半 │
│ - 出差餐饮补贴… │
└────────────────────────────────────────────────┘
↑ 问「餐饮补贴多少」,命中这块,
但块的开头在讲住宿,模型得自己从半途接上
② 标题层级切(Lite 的做法)
┌────────────────────────────────────────────────┐
│ 第 1 块(137 字) │
│ 《员工差旅管理制度》 › 第一条 住宿标准 │
│ ## 第一条 住宿标准 │
│ - 一线城市…不超过 500 元; │
│ - 二线城市…不超过 350 元; │
└────────────────────────────────────────────────┘
┌────────────────────────────────────────────────┐
│ 第 2 块(138 字) │
│ 《员工差旅管理制度》 › 第二条 餐饮与交通补贴 │
│ ## 第二条 餐饮与交通补贴 │
│ - 出差餐饮补贴为每人每天 150 元… │
└────────────────────────────────────────────────┘
↑ 一条一块,块头的路径直接告诉你"这是哪一条"差别不只在"切得整齐"。块头的「《文件名》 › 第几条」是切块脱离原文之后唯一的回家路: 检索命中一块,模型看到的第一个信息就是"这属于哪篇的哪一条";引用标注也能直接写上条款号。 而这条路径是零成本的——不调模型,就是几行字符串拼接。
如果一份文档里有表格,还有一条额外规则:表格行不可分割。一行表格本来是一条完整记录 (「一线城市 | 500 元」),被打散到两块里,"500"就和它的城市名分了家——检索命中了也 看不出这是哪个档位。超长表格只在行与行之间下刀,且每块都补上表头。
进阶:四种切块策略怎么选?
| 切块策略 | 原理一句话 | 适合场景 | 优缺点 |
|---|---|---|---|
| 递归字符切块 | 按分隔符优先级逐级下刀 | 通用基线 | 简单快,但无结构意识 |
| 标题层级切块 | 按 #/##/### 切,并把标题写进元数据 | 规范的技术文档、Wiki | 保留文档结构,上下文不丢。Lite 落地的就是这条(chunking.py),而且额外认中文的「第X章 / 第X条 / 一、 / (一)」编号——真实制度文件大多不是 Markdown |
| 语义切块 | 用 Embedding 判断相邻句子的相似度,拐点处切块 | 叙事散文、话题漂移多的长文 | 块更“懂意思”,但要多次调用 Embedding |
| 父子切块 | 小块用于精准匹配,命中后取回完整大块 | 合同、研报、技术白皮书 | 精度与上下文兼得,但要多存一层文档库 |
父子切块(ParentDocumentRetriever)代码要点
它的基本思路是:先用较小的子块完成向量匹配,命中后再把包含完整上下文的父块提供给 LLM。
python
from langchain_classic.retrievers import ParentDocumentRetriever
from langchain_classic.storage import InMemoryStore
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 父切块:保完整段落;子切块:负责高敏度命中
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=80)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=160, chunk_overlap=20)
# 向量库只存子块向量;docstore 单独存完整父文档
vectorstore = Chroma(collection_name="parent_demo", embedding_function=OpenAIEmbeddings())
docstore = InMemoryStore() # 生产可换 Redis/SQLite,避免重启丢数据
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=docstore,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
# retriever.add_documents(documents) # 自动在底层建立父子映射并入库进阶:切完再补上下文 —— 迟切块与上下文检索
痛点场景:一个 Chunk 被单独切出来后常常“失忆”。文档里写着“第三季度营收增长 3%”,可这一句被切成独立小块后,既不知道说的是哪家公司,也不知道是哪个季度。检索能命中,交给模型却答不准。
这是固定长度切块的先天缺陷:下刀的那一刻,上下文就断了。补救思路有三条,差别在于“上下文补在哪里”。
思路一:迟切块(Late Chunking,Jina)—— 先嵌入整篇,再切出块向量。
传统做法是“先切块、再逐块嵌入”,相当于先把文章剪成纸条、再逐张送去盖章;迟切块反着来:先把整篇文档喂给长上下文嵌入模型,让每个 token 的向量都“看过”全文,再按块把 token 向量池化(取平均)成块向量。这样向量天然带全局上下文,且零额外 LLM 调用。
代价有两个:受嵌入模型上下文窗口限制(约 8k token),长文必须先分段;而且它只救稠密向量,救不了 BM25 —— 因为原文一个字都没补。
思路二:上下文检索(Contextual Retrieval,Anthropic)—— 给每块补一段“真实文本”的头。
做法很朴素:入库前让 LLM 结合整篇文档,为每一块写 50–100 token 的上下文说明,拼到块前面。例如给“第三季度营收增长 3%”补上“本段来自 ACME 公司 2025 年 Q3 财报的营收分析部分”,再一起入库。
因为补进去的是真实文本,所以 BM25 与重排器同时受益。代价是每块都要过一次生成,语料大时前期成本明显,需要靠 prompt caching(把整篇文档缓存复用)压下来。
思路三:上下文块头(Contextual Chunk Headers)—— 补“结构化标签”。
最轻的一条:不用 LLM,只把块所属的章节路径、文档标题塞进块开头,例如“《2026 差旅制度》> 第二章 住宿标准 > 第三条”。它不生成新语义,却能让“第五条”这类悬浮条款不再有歧义,且几乎零成本。
| 方案 | 上下文注入在哪 | 额外 LLM 调用 | 是否帮到 BM25 | 主要约束 |
|---|---|---|---|---|
| 迟切块 | 向量 | 0 次 | ❌ | 受嵌入模型上下文窗口(约 8k)限制,长文要分段 |
| 上下文检索 | 真实文本 | 每块 1 次 | ✅ | 前期生成成本高,要靠 prompt caching 摊薄 |
| 上下文块头 | 结构化文本 | 0 次 | ✅ | 只补结构信息,不补语义 |
💡 怎么选:三者不互斥、可叠加。预算紧就先用零成本的“块头”打底;追求召回质量且能承担生成成本,上“上下文检索”;若只做稠密向量检索、又不想改动原文,用“迟切块”。真实项目里最常见的组合是“块头 + 上下文检索”。
下面是 15 行左右的最小示意:用 LLM 为块生成上下文头,再拼接入库。
python
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
def add_context(full_doc: str, chunk: str) -> str:
"""结合全文,为这一块写 50~100 token 的上下文头"""
prompt = (
f"<doc>{full_doc}</doc>\n"
f"<chunk>{chunk}</chunk>\n"
"用一句话说明这一块在全文中的位置与背景,只输出这句话。"
)
header = llm.invoke(prompt).content
return f"{header}\n\n{chunk}" # 上下文头在前,原文在后
# 逐块补头再入库;生产提示:整篇文档放进 prompt cache,避免每块重复付费
contextual_chunks = [add_context(full_doc, c) for c in raw_chunks]经验法则:上下文补在最前面,收益是全局的、不挑检索器;代价则可换算成“每块一次的生成成本”。语料越大,越该优先用零成本的“块头”,把 LLM 生成留给高价值文档。参考:Anthropic 上下文检索、Jina 迟切块、迟切块论文 arXiv:2409.04701。
配套脚本:code/s02_data_pipeline.py 里的 contextualize_chunks()(llm=None 时走零成本块头路径)与 build_context_header()。
进阶:RAPTOR —— 让长文档长出一棵“摘要树”
痛点场景:用户问“这份 200 页年报整体讲了什么”,可你的库里全是 400 字的碎块。任何单块都答不了全局问题,把所有块塞进模型又超长。这类“全局归纳型”问题,平铺式切块天生答不了。
比喻:RAPTOR 就是先给一本书编出“章节摘要 → 全书摘要”的目录树。问“整本书讲了什么”,直接读高层摘要;问某个细节,再回到叶子节点。
原理四步:
- 切块:把文档切成基础小块,作为树的叶子;
- 聚类:用嵌入向量把语义相近的块聚成一簇(常用 KMeans 或高斯混合);
- 摘要:让 LLM 为每一簇写一段摘要,这些摘要构成树的上层节点;
- 递归:把上一层摘要再聚类、再摘要,如此向上,直到只剩少数几个根节点。检索时可以在多层同时取候选,既拿到细节也拿到全局。
和父子切块的区别:父子切块的父节点只是“同一段原文切得更长”,本质还是原文;RAPTOR 的父节点是 LLM压缩后生成的新内容,因此能继续往上盖,长出全局视野。
成本:建树时有一次性 LLM 调用(每簇一次摘要),检索阶段不增加开销。它适合“这份报告整体结论是什么”这类全局问题;反之,语料频繁变动时,每次更新都要重建树,维护成本会明显上升。
| 方案 | 索引成本 | 擅长的问题 | 维护复杂度 |
|---|---|---|---|
| RAPTOR | 高(聚类 + 逐层 LLM 摘要) | 全局归纳、跨章节综述 | 中,语料变动需重建树 |
| 父子切块 | 低(只多存一层原文) | 细节问题回填上下文 | 低 |
| GraphRAG | 很高(抽实体关系建图) | 多跳关系、实体关联推理 | 高 |
下面是一段 20 行左右的骨架代码,串起“聚类 → 摘要 → 递归”:
python
import numpy as np
from sklearn.cluster import KMeans
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
llm, embed = ChatOpenAI(model="gpt-4o-mini"), OpenAIEmbeddings()
def summarize(cluster_texts: list) -> str:
joined = "\n\n".join(cluster_texts)
return llm.invoke(f"请把下面这些片段概括成一段摘要:\n{joined}").content
def build_tree(leaves: list, depth: int = 2) -> list:
"""自底向上建树:聚类 → 摘要 → 再聚类,返回所有层的节点"""
all_nodes, current = list(leaves), leaves
for _ in range(depth):
vecs = np.array(embed.embed_documents(current))
k = max(2, len(current) // 4) # 每簇约 4 个节点
labels = KMeans(n_clusters=k, n_init="auto").fit_predict(vecs)
current = [summarize([current[i] for i in np.where(labels == c)[0]])
for c in range(k)] # 每簇生成一段摘要
all_nodes.extend(current) # 上层节点同样入库
return all_nodes边界:RAPTOR 解决的是“全局问题”,不是“精度问题”。如果用户问的都是具体条款,先把基础切块与重排做好,再考虑建树。参考:RAPTOR 官方实现、RAPTOR 论文 arXiv:2401.18059。
配套脚本:code/s02_data_pipeline.py 里的 build_raptor_tree() 与 kmeans_labels()。
命题切块与“块到底切多大”
痛点场景:一段话里塞了三件事,检索命中后答案半对半错;可若切得太碎,又拼不回完整意思。
命题切块(Proposition Chunking):把段落拆成一堆自包含的原子命题 —— 每句独立、指代消解、带上主语。例如原文“公司去年营收 10 亿,其中 Q3 增长 3%”,会被拆成“ACME 公司 2024 年营收 10 亿元”“ACME 公司 2024 年 Q3 营收同比增长 3%”两条。每条都能独立成立,检索命中精度高。
代价也直白:索引条目会膨胀数倍,而且每块都要过一遍 LLM 改写,成本高。所以它适合“高价值小语料”,比如核心制度、关键合同条款,不适合海量网页。
那“块到底切多大”有没有最优解? 没有普适最优:块太小丢上下文,块太大稀释语义,不同语料的最佳点各不相同。正确做法是拿数据说话 —— 准备 20 条固定问题,覆盖四类:
| 问题类型 | 考察什么 |
|---|---|
| 可直答 | 基础召回是否命中 |
| 需跨段 | 上下文是否被切断 |
| 无答案 | 会不会硬凑答案(幻觉) |
| 版本冲突 | 新旧制度是否串味 |
同一批问题分别跑不同 chunk_size 配置,比 Recall@K 与答案正确率,把最佳配置落到你自己的语料上。这正是 11.9 回归集的用法:切块参数不该拍脑袋,而该由回归集定。参考:RAG_Techniques 命题切块 Notebook。
拓展:切块质量的“体检报告”
切块好不好,不能靠感觉,建议在管道里顺手输出一份“体检报告”:
| 体检项 | 判定标准 | 说明 |
|---|---|---|
| 块大小分布 | 最大值/最小值/均值是否远离设定值 | 若大量块“顶格”说明有内容被硬切 |
| 句尾完整率 | 以句号/问号结尾的块占比 | 越高越好,说明没腰斩句子 |
| 标题丢失率 | 无任何标题元数据的块占比 | 对标题切块很重要 |
| 重复率 | 块与块的重叠文本占比 | overlap 设置过大会造成信息冗余 |
复杂文档:真实资料往往不够规整
上面的管道处理的是结构尚可辨认的文档。企业资料中还常见下面几类更难处理的情况:
| 文档形态 | 难在哪 | 普通解析器的处理结果 |
|---|---|---|
| 几百页的扫描件 PDF | 文字是“图片”不是文本,必须 OCR | pypdf / pymupdf 提出来是空字符串 |
| 财务报表 / 实验数据表 | 合并单元格、跨页表格、多层表头 | 表格被拆成一行行“散装文本”,列语义全丢 |
| 论文 / 理工文档 | LaTeX 公式、上下标、脚注 | 公式变成乱码,检索“E=mc²”永远命中不了 |
| 图文混排的产品手册 | 截图里藏着关键步骤文字 | 图片被直接丢弃,正好漏掉用户最需要的部分 |
轻量解析器像一个只熟悉印刷书籍的图书管理员:处理排版规整的文字很快,遇到手写病历、扫描图片和复杂表格时就容易丢失信息。此时需要按文档形态分流,分别使用 OCR、版面分析和表格识别工具。
先分清两种 PDF,再决定要不要上重器
财务从 Word 点“另存为 PDF”得到的,通常是文字层 PDF:用鼠标能选中“500 元”,pymupdf 抽出来就是字。行政把签字版往复印机上一搁再扫成 PDF,得到的是扫描件:看起来像文档,里面其实是一张图。两种文件后缀都叫 .pdf,解析路径完全不同。
可以把它想成医院里的病历:
| 手里拿的是什么 | 生活里像什么 | 该用谁 |
|---|---|---|
| 文字层 PDF(能选中复制) | 打印清楚的化验单,数字就印在纸上 | pymupdf:快、轻、离线测试也能跑 |
| 扫描件 / 双栏论文 / 跨页表 / 公式 | 手写病历复印件,字在图里 | MinerU 4.x:版面分析 + OCR + 表格/公式还原 |
| Word / PPT / Excel 混着来 | 科室把病历、幻灯、表格塞进同一个信封 | Docling 或 Unstructured |
testdata/差旅管理制度_2026.pdf 是第一种,而且它就是检索原文,不是旁边另放的玩具夹具。配套脚本用 pymupdf 当场抽出“住宿费上限为每人每天 500 元”;Word 制度走 python-docx 按段落拼回同样带页标题的纯文本。如果 PDF 抽出来是空字符串,管道会直接报错,而不是把空白切成块写进向量库——空白块一旦入库,后面检索命中的全是空气。
现代高保真解析器:MinerU 4.x 与 Docling
新一代文档解析器把「版面分析 + OCR + 表格识别 + 公式还原」打包成一条流水线,直接输出结构干净的 Markdown/JSON:
| 解析器 | 出品方 | 拿手绝活 | 一句话定位 |
|---|---|---|---|
| MinerU 4.x | 上海 AI 实验室 OpenDataLab | 公式转 LaTeX、表格转 Markdown、阅读顺序还原;默认本地、不上传 | 中文论文、研报、扫描件的高保真路径 |
| Docling | IBM | 表格结构还原、图文混排、和 LlamaIndex/LangChain 原生集成 | 企业混排文档的“瑞士军刀” |
💡 选型直觉:文档以论文、研报、公式、中文扫描件为主 → 选 MinerU;文档以 Word/Excel/PPT 混合、要接框架管道 → 选 Docling。两者都支持本地部署,涉密数据不出内网。
MinerU 4 不是 import 之后当场返回字符串。 当前 4.x(包名 mineru,Python >=3.10,<3.15)默认本地优先:小模型走 ONNX + llama.cpp,NVIDIA 机器再装 mineru[full]。库模式要先起本地服务,再用客户端提交任务;一次性转文件用 mineru-kit。官方文档:MinerU 使用说明、GitHub 仓库。
一次性把扫描件转成 Markdown(不走库缓存):
bash
# Python >=3.10,<3.15;默认 CPU/Vulkan,NVIDIA 再装 mineru[full]
uv pip install -U "mineru>=4.0,<5"
mineru-kit parse 扫描件_差旅制度.pdf -o 扫描件_差旅制度.md --tier standard库模式适合“这份文件以后还要按页读、按关键词搜”。先起本地服务,再提交解析:
python
# 先在另一个终端执行:mineru server start
# 文档默认留在本地;只有显式配置 --remote 才会上传。
from mineru import DoclibClient
from mineru.doclib import ParseRequest
import time
client = DoclibClient()
job = client.ensure_parse(ParseRequest(path="扫描件_差旅制度.pdf", tier="standard"))
for pid in job.wait_parse_ids:
parse = client.get_parse(pid)
while parse.status in ("pending", "parsing"):
time.sleep(1)
parse = client.get_parse(pid)
page = client.read_content(f"doc:{job.short_id}/tier:standard/page:1")
print(page.content[:400])tier 可以按难度换挡:flash 只做预览,standard 是日常阅读默认档,advanced 留给双栏、跨页表、公式这种硬骨头,更慢也更吃机器。
教学脚本把这条路做成可选开关,而不是硬依赖。s02_data_pipeline.py 里的 load_pdf():环境变量 RAG_USE_MINERU=1 时先调 try_mineru_markdown();没装包、没起 mineru server、超时,一律退回 pymupdf。离线单元测试不会去连一个并不存在的本地服务,requirements.txt 也不把 MinerU 写成必装——它要下模型、起服务,塞进课堂 venv 会把 39 个离线测试拖垮。
财务邮箱里那份能选中“500 元”的差旅 PDF,用 pymupdf 两秒就能入库;隔壁实验室把论文扫描件丢过来、公式糊成马赛克,再开 MinerU。两种文件都叫 PDF,混用一种解析器,不是省事,是把空文本或乱码写进向量库。
用 Docling 把一份带表格的 PDF 一步变成“RAG 友好”的 Markdown:
python
# pip install docling
from docling.document_converter import DocumentConverter
converter = DocumentConverter()
result = converter.convert("财报_2025.pdf")
# 导出为 Markdown:表格自动还原成 | a | b | 格式,标题保留层级
md = result.document.export_to_markdown()
print(md[:500])
# 也可以拿结构化 JSON:每个表格、每张图片、每个标题都是独立节点
for table in result.document.tables:
df = table.export_to_dataframe() # 直接得到 pandas DataFrame
print(df.head())表格、公式、图文混排的入库策略
解析出来之后,不同形态的信息要走不同的入库通道,不能用一把尺子切:
| 信息形态 | 推荐策略 | 为什么 |
|---|---|---|
| 表格 | 每张表独立成块,表头 + 摘要行写成自然语言一并嵌入;不跨表切块 | 切开表格 = 拆散一家人,检索命中一半永远答不全 |
| 公式 | 保留 LaTeX 原文入库,同时补充自然语言说明 | 向量模型对 LaTeX 符号可能不敏感,自然语言用于语义命中,LaTeX 用于精确还原 |
| 图片 | 用多模态模型生成图片描述(caption)与图片一起入库;或直接走 11.14 的多模态 RAG | 让“图片里的话”也能被文本检索命中 |
| 扫描件 | 先 OCR(MinerU/Docling 内置),按置信度抽样人工质检 | OCR 不是 100% 准,低置信度段落混进库里就是定时炸弹 |
复杂文档没有一种解析器可以全部处理好。更稳妥的流程是先按形态分流,再使用适合的解析器,并通过抽样质检确认文字、表格和版面关系是否保留。
解析完了还有一条分叉:切块检索,还是先编成 Wiki
pymupdf 和 MinerU 解决的是「文件变成字」。字有了以后,常见两条路:
| 分叉 | 干什么 | 提问时翻什么 | 本章走哪条 |
|---|---|---|---|
| 切块 + RAG | 把字切成带出处的 Chunk,建索引 | 生肉切块,现检索、现生成 | 主角。差旅 500 元、年假 5 天都走这条 |
| LLM Wiki(知识编译) | 让模型把字写成主题页、实体页、来源页,互相 [[链接]] | 先读已经整理过的 Wiki,必要时再回原文 | 邻居。11.1 的边界表里有它,这里不展开 |
别把第二条叫做「编译式 RAG」。它编译的是知识页,不是检索索引;后面若再对 Wiki 页做向量检索,那是「Wiki 做好了,再 RAG 这本 Wiki」,和 testdata 上的制度问答不是同一套系统。制度条款要能指回「哪一页写了 500 元」,Wiki 编译会掺进归纳和推断,不能当唯一出处。想看完整实现,去 nashsu/llm_wiki;本章脚本继续切块、检索、引用。
补全生产链路:来源、版本、去重与权限
只把文本切干净还不够。企业知识像办公室里流转的合同:你必须知道它从哪来、哪天生效、谁能看、是否已经被新版替代。否则检索器可能把“2025 年住宿上限 450 元”和“2026 年住宿上限 500 元”一起交给模型,模型只能猜。
每个 Chunk 至少保留这些元数据:
| 字段 | 解决的问题 | 示例 |
|---|---|---|
source_id / source_uri | 能否追到原文件 | TRAVEL-2026-07#p2 |
source_hash / chunk_id | 内容是否变化、写入能否幂等 | sha256:...:chunk-3 |
effective_from/to | 新旧制度冲突时选谁 | 2026-07-01 |
status | 草稿、现行、废止、隔离 | active |
tenant / acl | 谁能检索到 | tenant=acme, acl=employee |
parser_version / embedding_version | 哪个处理批次产生 | docling-2.x / embed-v3 |
page / bbox | 引用如何跳回原文 | page=2, bbox=[...] |
去重不能只看文件名
同一制度可能同时出现在邮件附件、网盘和 Wiki。文件名不同,但内容几乎一样。三份重复内容进入 Top-K 会制造“多数票错觉”,也会挤走真正不同的证据。
- 完全重复:对规范化正文做内容哈希,哈希相同只留一份;
- 近重复:用 MinHash、SimHash 或向量相似度识别轻微改名、换页眉的副本;
- 版本关系:相似不代表重复。新旧制度要保留谱系,但默认检索只查
active,历史问题才显式查旧版; - 权威来源:内部正式制度应压过论坛截图和网页转载,来源等级要进入排序或过滤。
切块好坏最终由“能不能回答”决定
句尾完整率只能做体检,不能证明检索效果。更可靠的办法是准备一批问题和相关 Chunk 标注,对不同切块配置做对照:
| 实验变量 | 观察指标 | 常见现象 |
|---|---|---|
chunk_size / overlap | Recall@K、重复率、上下文 Token | 块太小缺背景,块太大语义被稀释 |
| 标题是否继承 | 精确命中率、无标题率 | 正文里的“第五条”脱离标题后含义不清 |
| 父子切块 | 子块 Recall、父块生成忠实度 | 找得准但回填过大,也会带来噪声 |
| 表格独立切块 | 数字题正确率 | 只命中某一行却丢表头,会把列含义答反 |
配套 code/testdata/ 故意放入现行制度、废止旧版和不可信网页,外加 真实RAG演示文档/ 四份标准案例(协作手册、差旅报销、设备故障、HR 制度);每份 8 到 14 页不等,格式是 md / pdf / docx / html 混排。现行差旅 差旅管理制度_2026.pdf 就是检索原文,不是旁边另放的玩具夹具。s02_data_pipeline.py 按后缀选解析器,为 Chunk 生成稳定 ID、状态与信任级别,并输出语料体检报告。shared_corpus.py 抽出文本后再按 ## 第 N 页 切成 86 个页级 Chunk,后续各小节脚本的检索、评估与引用演示用的都是这份真实语料。
权威官方参考
- LangChain Text Splitters 官方指南
- LangChain ParentDocumentRetriever 官方文档
- MinerU 4.x:面向 RAG/预训练的高保真文档解析(OpenDataLab)
- MinerU 官方文档:CLI 与 mineru-kit
- PyMuPDF(pymupdf)官方文档
- python-docx 官方文档
- Unstructured:几十种格式的统一结构化解析
- Docling:IBM 开源文档解析器,支持表格/公式/图文混排
- Docling 官方文档
- Marker:PDF 转 Markdown 高保真工具
- Anthropic 上下文检索(Contextual Retrieval)
- Jina 迟切块(Late Chunking)
- 迟切块论文:Late Chunking in Long-Context Embedding Models(arXiv:2409.04701)
- RAPTOR:递归摘要树检索(GitHub)
- RAPTOR 论文:Recursive Abstractive Processing for Tree-Organized Retrieval(arXiv:2401.18059)
- RAG_Techniques:命题切块(Proposition Chunking)实践 Notebook
- nashsu/llm_wiki:解析之后的另一条路,把资料编译成 Wiki(不是解析器)