Skip to content

11.2 数据源又脏又乱怎么办?—— 文档解析、清洗与切块 ​

痛点场景:真实资料通常不够规整:扫描版 PDF 可能双栏错乱,Word 中混有表格,网页导出带着广告,页眉、页脚和水印还会反复出现。如果不先清理就写入向量库,检索和回答质量都会受到影响。 本节我们用「痛点 → 思路 → 代码实现 → 拓展」四段式,把“数据准备层”彻底打通。


痛点:垃圾进,垃圾出(Garbage In, Garbage Out) ​

先看一组真实的“车祸现场”:

数据源形态典型脏点后果
扫描 PDF缺字、乱码、双栏混读、需要 OCR检索到一堆“乱码碎片”,完全没法用
带表格的 Word/Excel表格被拆行、表头丢失大模型看错列,答错数据
网页导出的 HTML导航、广告、版权声明混入正文语义向量被噪声稀释
内部旧文档页眉页脚、“第 38 页”、水印相同噪声反复入库,污染检索结果

为什么解析这一步决定了整个 RAG 的上限? 因为后面所有环节(切块、嵌入、检索、生成)都是在“已解析出的文本”上进行的。这一步出错,后面再高级的技术也救不回来。


思路:三步走,把“脏数据”变成“干净 Chunk” ​

💡 思路:三步走,把“脏数据”变成“干净 Chunk”

第 1 步:解析(Parsing)—— 把二进制文件变成文字。核心是“按格式选对工具”:

文件格式轻量方案重量级/高保真方案
PDFpypdf、pymupdf(fitz)MinerU、Marker(还原公式/多栏/表格为 Markdown)
Wordpython-docx、docx2txtUnstructured(统一几十种格式)
HTMLBeautifulSoupunstructured 的 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. 清洗是显式的一步,而不是直接拿脏文本切块。上面那份样本里,“第 1 页”“机密文件严禁外传”会被清掉,正文条款留下;
  2. 元数据注入了 source / category / chunk_index,后面 11.5 才能按分类过滤,11.9 才能把答错的那一刀追到某一页;
  3. 分隔符针对中文定制,优先在句号、问号、分号处下刀,少把“住宿上限 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 就是先给一本书编出“章节摘要 → 全书摘要”的目录树。问“整本书讲了什么”,直接读高层摘要;问某个细节,再回到叶子节点。

原理四步:

  1. 切块:把文档切成基础小块,作为树的叶子;
  2. 聚类:用嵌入向量把语义相近的块聚成一簇(常用 KMeans 或高斯混合);
  3. 摘要:让 LLM 为每一簇写一段摘要,这些摘要构成树的上层节点;
  4. 递归:把上一层摘要再聚类、再摘要,如此向上,直到只剩少数几个根节点。检索时可以在多层同时取候选,既拿到细节也拿到全局。

和父子切块的区别:父子切块的父节点只是“同一段原文切得更长”,本质还是原文;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文字是“图片”不是文本,必须 OCRpypdf / 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、阅读顺序还原;默认本地、不上传中文论文、研报、扫描件的高保真路径
DoclingIBM表格结构还原、图文混排、和 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 会制造“多数票错觉”,也会挤走真正不同的证据。

  1. 完全重复:对规范化正文做内容哈希,哈希相同只留一份;
  2. 近重复:用 MinHash、SimHash 或向量相似度识别轻微改名、换页眉的副本;
  3. 版本关系:相似不代表重复。新旧制度要保留谱系,但默认检索只查 active,历史问题才显式查旧版;
  4. 权威来源:内部正式制度应压过论坛截图和网页转载,来源等级要进入排序或过滤。

切块好坏最终由“能不能回答”决定 ​

句尾完整率只能做体检,不能证明检索效果。更可靠的办法是准备一批问题和相关 Chunk 标注,对不同切块配置做对照:

实验变量观察指标常见现象
chunk_size / overlapRecall@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,后续各小节脚本的检索、评估与引用演示用的都是这份真实语料。


权威官方参考 ​