从零到一搭建企业级智能问答系统:Ch04 · 文档摄入与父子切片

Ch04 · 文档摄入与父子切片

覆盖提交85ac3a7(036) · 501e11d(037) · d1796b2(039) · d3d460e(041) 代码位置 :GitHub 搜索 lingluo1hao / enterprise-aiingest/loaders.py 解析 · chunk.py 父子切片 · pipeline.py 主流程),上面每个提交号都能逐条对照改动 难度 :★★★★★ 阶段 :Day 9--12 关键词:结构感知分块 / Small-to-Big / PDF 解析 / 连通分量 / 纯 Python 切分


本章导读

前三章做的是地基 :第 1 章把模型跑起来,第 2 章把提示词管起来,第 3 章把仓库、密钥、文档收拾干净。到这一章为止,系统里还一条知识都没有------模型能跑、会答,但它不知道你的手册里写了什么。

这一章开始进入检索篇,第一件事不是选向量库,是分块。

原因很硬:分块(Chunking)是 RAG 的第一性原理,它决定了检索的上限。 再好的 reranker 也救不了被腰斩的表格------因为 reranker 只能在"已经召回的坏候选"里排序,候选本身是残的,排序再准也没用。

这笔账有多真?项目第一版用最朴素的"固定长度切 600 字",上线当天就暴露三个问题:

  1. 表格被腰斩 ------ 一个参数表切成两半,两半单独看都是废话
  2. 章节被打散 ------ 同一节内容散落在三个 chunk,检索回来上下文不全
  3. 图片全部丢失 ------ PDF 里的接线图、结构图,全变成空白

第 036--041 次提交建了完整的 ingestion 引擎解决这三个问题,最终形成**父子切片(Small-to-Big)**主路径。真实数据:Jimi 设备手册 PDF 27 个章节 → 25 个父块 + 309 个子块 = 334 个分片

还记得 ch03 那句「看不见的事,决定项目活不活过第一个月」吗?分块是同一类事------它不报错、不报警,只是悄悄把你检索质量的天花板压低三成。等你在 Ch06 调混合检索、Ch07 调 reranker 怎么都调不上去的时候,根因往往在 Ch04 这一步。
本章还会讲到本项目最"硬"的一个工程决策 :为了绕开一个无法被 try/except 捕获的段错误,把 langchain 的分词器整个替换成纯 Python 实现。这个决策背后的判据(替换成本 < 排查成本),比父子切片本身更值得带走。


本集学习目标

学完这一章,回到这张表逐一自查------四条,一条都不能少:

# 目标 达标标准
1 搞懂分块为什么决定检索上限 能说出「准 vs 全」的核心矛盾;会用一个问题判断 chunk 是否合格
2 会实现父子切片 懂四条分发路径、父块 id 为什么用 md5(路径+内容)、父块为什么不参与检索
3 能处理 PDF 的表格与图片 知道表格"渲染成图"的取舍逻辑;懂并查集 + 连通分量怎么抽出一张图
4 理解「绕开 langchain」这个决策 知道 SIGSEGV 抓不到;会用「替换成本 < 排查成本」做依赖选型判断

目标 1 是认知地基------不懂信噪比权衡,后面所有调参都是瞎调;目标 2 是本章的工程量主体,也是你回去能直接抄走的部分;目标 3 是最"算法"的一段,读懂了会明白为什么并查集比 DFS 更适合这个问题;目标 4 是通用工程判断力,跟 RAG 无关,跟你以后每一次选依赖有关。带着这四个目标往下读,读完回来打钩。


📐 理论基石:分块粒度 = 信噪比(P0-04 §3)

动手前先想清一件事:RAG 检索的质量上限,在分块这步就定了(P0-04 §3)。

  • 块太大 → 噪声淹没有效信号、占 token、稀释相关性。一个 3000 字的块里塞了五个主题,embedding 之后向量"平均化",什么都不像,谁都匹配不上。
  • 块太小 → 语义不完整、生成缺背景。半张参数表、半段代码,模型拿到手也答不出来。
  • 切分位置错了 → 比大小错了更糟。表格从中间断开、代码块从中间断开,结构一断,语义就断了

所以这是一道信噪比权衡题 ,不是「切多细」的机械活。本项目的答案是父子切片(Small-to-Big)------检索用小子块(高信噪比、精确命中),生成时回退到父窗口(完整背景)。代码里两条参数独立:

python 复制代码
# ingest/chunk.py:50
PARENT_WINDOW_CHARS = 550   # 子片段前后各 550 字滑动窗口(总长 ≪ 8192 字节)
# ingest/chunk.py:53-67
# _window_around(child, parent, n):用 parent.find(child) 定位,确保父子同章

一句话分块不是预处理,是检索系统的设计决策------你在这一步就替模型决定了"它最远能看到什么"。
理论(P0-04)告诉你「为什么要用父子切片」;这章的工程告诉你「怎么用 550 字滑动窗口落地」。先懂信噪比,才看得懂后面 Ch07 的 reranker 为什么还要再滤一遍噪声。

先记住这条主线,现在开始动手------第一件事,看清楚整条 ingestion 流水线长什么样,以及固定长度切分到底死在哪。


第一部分 · 全景与分块策略:先把「固定长度为什么不行」钉死

这一部分拿下目标 1。 很多人一上来就问"该切 500 字还是 1000 字",方向一开始就错了------粒度是第二位的问题,第一位是"你有没有破坏结构"。这一部分先把整条链路摆出来,再用一张被腰斩的表格,把固定长度的死因钉死。

1.1 全景:ingestion 流水线

图 1 完整摄入链路。核心是最后一步:小块去检索(准),大块去回答(全)

css 复制代码
文档 → loader(格式解析)→ chunker(结构感知分块)→ embed(向量化)→ store(入库)
         ↓                        ↓
    PDF/MD/HTML/XLSX/PPTX    父块 + 子块
    + 图片抽取                + 表格渲染为图
模块 文件 职责
加载器 ingest/loaders.py 多格式解析、图片抽取、密级推导
分块器 ingest/chunk.py 结构感知分块、父子切片
向量化 ingest/embed.py 批量 embedding
存储 ingest/store.py Milvus 写入、字节截断
指纹 ingest/fingerprint.py 增量判定(避免重复摄入)
流水线 ingest/pipeline.py 编排、租户推导、状态查询

1.2 四种分块策略的取舍

策略 做法 优点 缺点 本项目
固定长度 按字符数切,带 overlap 简单、通用 破坏结构、表格腰斩 ❌ 已淘汰(LegacyChunker
结构感知 按标题层级切 保留章节完整性 长章节仍需再切 ✅ 基础能力
父子(Small-to-Big) 小块检索、大块回答 准 + 全兼得 实现复杂、存储翻倍 主路径
语义分块 按 embedding 相似度切 主题连贯 成本高、需额外模型 未采用

为什么没上语义分块 :它要额外跑一次 embedding,成本翻倍,而结构感知已经把"章节"这个天然的语义边界用上了------能用文档自带的结构,就别花钱去猜结构

1.3 为什么固定长度会失败

看一个真实场景:

arduino 复制代码
原文(一个参数表):
┌──────────────────────────────┐
│ 参数名    │ 默认值  │ 范围     │
│ 心跳间隔   │ 60s    │ 10-3600  │   ← chunk N 结束(600 字)
│ 供电电压   │ 12V    │ 9-36     │   ← chunk N+1 开始
│ 波特率    │ 9600   │ ...      │
└──────────────────────────────┘

用户问:"心跳间隔的默认值是多少?"
→ 检索到 chunk N(表头 + 第一行)→ 能答
用户问:"供电电压范围是多少?"
→ 检索到 chunk N+1(没有表头!)→ "9-36" 是什么的电压?模型不知道 → 答错或不敢答

表格被腰斩后,后半部分失去了表头,语义不完整。

注意这里的关键:不是"切小了"的问题,是"切错位置"的问题 。哪怕你把块调到 2000 字,只要切分点落在表格中间,一样腰斩。这就是为什么固定长度这条路走不通------它根本不知道自己在切什么

第一部分小结 · 对照自查

  • 一条链路------loader → chunker → embed → store,指纹模块负责增量
  • 一个判断 ------分块是信噪比权衡:块太大噪声淹没信号,块太小语义不完整,切错位置比切错大小更糟
  • 一个标准------单独看这个 chunk,人能看懂吗?能回答什么问题吗?看不懂就是坏块
  • 一个选择------父子切片是主路径;语义分块没上(能白用文档结构就别花钱猜)

到这里,目标 1 完成------你现在知道分块为什么决定上限,也知道固定长度死在哪。接下来进入工程量最大的部分:父子切片怎么落地


第二部分 · 父子切片的实现:小块检索,大块回答

这一部分拿下目标 2。 先看清解法全貌,再钻进代码。路线是四步:解法示意 → 分发逻辑 → 父块 ID 稳定性 → 真实数据 。其中「父块 ID 怎么生成」看着像细节,其实是能不能增量更新的命门。

2.1 父子切片的解法

复制代码
整章(父块,2000 字,不参与检索)
   ├── 子块 1:心跳间隔参数(350 字)  ← 检索单元
   ├── 子块 2:供电电压要求(320 字)  ← 检索单元
   └── 子块 3:通讯协议列表(400 字)  ← 检索单元

命中子块 2 → 透传父块给 LLM → 模型看到整章上下文 → 表格完整 → 答对

精髓 :检索要 (小块,语义集中),生成要(大块,上下文完整),用一个 id 把两者绑起来。

为什么父块不参与检索? 父块文本长(2000 字),embedding 后会"平均化"------包含多个主题的向量反而什么都不像,检索效果差。这就是"语义分散"。所以父块的定位是生成期的上下文来源,不是检索期的候选。

2.2 分块器的分发逻辑

python 复制代码
# ingest/chunk.py:363
def split(self, raw: RawDoc) -> List[Chunk]:
    # 已按章节聚合的 PDF 单元(loader 设置了 section_path):
    # 走「整章为父、细切为子」的 small-to-big 父子切片
    if raw.section_path is not None:
        return self._split_prebuilt_section(raw)
    ext = os.path.splitext(raw.source)[1].lower()
    if ext in (".md", ".markdown"):
        return self._split_markdown(raw)
    if ext in (".html", ".htm"):
        return self._split_html(raw)
    # PDF 降级路径(PyPDFLoader 无结构)及其它无显式结构格式:图感知分块
    return self._split_figure_aware(raw)

四条路径

路径 触发条件 说明
_split_prebuilt_section PDF 已按章节聚合 主路径(父子切片)
_split_markdown .md 文件 #~#### 标题切
_split_html .html <h1>~<h6>
_split_figure_aware 其他/降级 图感知分块

注意ingestion 只有这一条主切片路径。"固定长度/段落/滑动窗口"只是内部细切子块的手法,不是独立的切片策略。这个认知很重要------很多人以为自己在"选分块策略",其实只是在调子块怎么切。

2.3 父块 ID 的稳定性

python 复制代码
# ingest/chunk.py:397
pid = hashlib.md5(
    ("§".join(path) + "\n" + content).encode("utf-8")).hexdigest()[:16]
pcontent = content  # 父窗口 = 整段 section 文本(含标题路径前缀)

为什么用 md5(章节路径 + 内容)?

需求 说明
幂等 同一文档重复摄入,父块 id 不变 → 可以做增量更新
唯一 不同章节内容不同 → id 不同
可推导 不需要额外存储映射表,从内容就能算出 id
简短 取前 16 位,够用且不占空间

如果 id 随每次摄入变化,就无法判断"这个分块是否已存在",只能全量重建------对百万级文档是灾难。而且 Ch12 的文档去重依赖 parent_id 做身份键({source}#{parent_id}#{chunk_index}),id 一漂,去重直接失效

2.4 真实数据

本项目实测(Jimi 设备手册 PDF):

复制代码
27 章节 → 25 个父块 + 309 个子块 = 334 个分片

平均每个父块切出 12.4 个子块 。注意 27 个章节只产出 25 个父块------有 2 个章节因为内容过短被合并,这是结构感知切分在真实文档上的正常表现。

第二部分小结 · 对照自查

  • 一个结构 ------父块(整章,不检索)→ N 个子块(350-400 字,检索单元),用 parent_id 绑定
  • 四条路径 ------_split_prebuilt_section(主)/ markdown / html / figure_aware(降级)
  • 一个细节 ------父块 id = md5(章节路径 + 内容)[:16],幂等、唯一、可推导、简短
  • 一个实测------27 章节 → 25 父 + 309 子 = 334 分片,平均每父 12.4 子

到这里,目标 2 完成------父子切片从原理到代码你都走了一遍。接下来处理 ingestion 里最脏的活:PDF


第三部分 · PDF 解析:表格与图片

这一部分拿下目标 3。 PDF 是 ingestion 里唯一的"硬骨头",因为它根本不是结构化文档。路线是三步:先搞清它为什么难 → 表格怎么解 → 图片怎么抽。其中图片抽取用到的并查集,是本章最"算法"的一段。

3.1 PDF 为什么难

PDF 本质上是打印指令("在这个坐标画这条线"),不是结构化文档。所以:

内容 传统文本解析 后果
正文段落 ✅ 能提取 ---
表格 ❌ 提取成乱序文本 行列关系丢失
图片/图表 ❌ 完全丢失 接线图、结构图全没了
标题层级 ⚠️ 需靠字号推断 可能推断错

3.2 表格的解法:渲染成图片

本项目没有硬解析表格结构,而是:

  1. 识别表格区域(bbox)
  2. 把该区域渲染成图片
  3. 在正文里留下 <!-- FIG: xxx.png --> 占位符
python 复制代码
# ingest/loaders.py:182
def _is_valid_table(rows, bbox, page_rect) -> bool:
    """判定某区域是否是有效表格(避免误判)。"""
python 复制代码
# ingest/loaders.py:109
def _table_to_markdown(rows) -> Optional[str]:

为什么这样解?

方案 优点 缺点
解析成 Markdown 表格 结构化、可检索 复杂表格(合并单元格)几乎必然解析错
渲染成图片 100% 保真 图片内容不可检索

本项目选了保真路线,因为:

  • 设备手册里的表格经常有合并单元格、跨页表格
  • 解析错比不解析更糟------模型会拿到错误的数字
  • 图片可以被多模态模型理解,或展示给用户

这条取舍的判据值得记住:错误的数字比缺失的数字危险得多。用户查不到,最多翻手册;用户查到错的参数去配设备,是要出事的。

3.3 标题层级推断

python 复制代码
# ingest/loaders.py:144
def _probe_font_levels(pages_items):
    """通过统计字号分布推断标题层级。

    思路:正文占绝大多数(字号集中),标题字号更大且出现频次低。
    先统计字号直方图,再把"显著大于众数"的字号定为各级标题。
    """

这是一个统计推断问题,不是规则匹配------因为不同文档的标题字号完全不同。写死"16pt 是 H1"在 A 文档对、在 B 文档就错。

3.4 图片抽取:连通分量算法(037)

这是本章最"算法"的部分。

问题 :PDF 里的一张图(比如接线图)由多个独立的绘图元素组成(几十条线、几十个矩形)。如何判断"这些元素属于同一张图"?

答案 :它们在坐标上是连通的

python 复制代码
# ingest/loaders.py:602
def _extract_figures(path: str) -> List[List[str]]:
    """PDF 图片通用抽取。

    算法:并查集 + 连通分量
      1. 遍历页面所有绘图元素(线条、矩形、路径),记录 bbox
      2. 计算任意两个元素的距离
      3. 距离 < 阈值 → 认为连通(union)
      4. 最终每个连通分量 = 一张图
      5. 按 bbox 裁剪页面,导出为图片
    """

并查集原理

python 复制代码
class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))

    def find(self, x):
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]  # 路径压缩
            x = self.parent[x]
        return x

    def union(self, a, b):
        ra, rb = self.find(a), self.find(b)
        if ra != rb:
            self.parent[rb] = ra

为什么用并查集而不是 DFS/BFS?

方法 复杂度 适合
DFS/BFS O(N + E) 边已知
并查集 O(N α(N)) 边需要动态计算(距离 < 阈值)

因为"哪些元素连通"是边算边判断 的(每对元素都要算距离),并查集可以边判断边合并,不需要先建图。用 DFS 就得先把 O(N²) 条边全算出来建好图,再遍历------多一轮,还多一份内存。

效果 :抽出来的图存到 figures/ 目录,正文留占位符,LLM 回答时可以"见图说话"。

第三部分小结 · 对照自查

  • 一个本质------PDF 是打印指令不是结构化文档,表格和图片必然丢
  • 一个取舍------表格渲染成图(保真优先),因为解析错比不解析更危险
  • 一个推断------标题层级靠字号直方图统计推断,不能写死规则
  • 一个算法------图片抽取 = 并查集 + 连通分量,选它是因为边要"边算边合并"

到这里,目标 3 完成。接下来是本章最硬的一个工程决策------它跟 RAG 无关,跟你以后每一次选依赖有关。


第四部分 · ★ 关键决策:绕开 langchain(041)

这一部分拿下目标 4,也是全章最值得带走的部分。 它讲的是:当某个第三方库在你的目标环境上"炸了",而且炸的方式你连捕获都捕获不到时,该怎么决策。

4.1 问题

langchain_text_splitters 在部分 Python 环境下 import 即段错误(SIGSEGV)

4.2 为什么这是致命的

python 复制代码
try:
    from langchain_text_splitters import RecursiveCharacterTextSplitter
except Exception:
    fallback_to_python()      # ❌ 完全无效!

段错误(SIGSEGV)是操作系统信号,不是 Python 异常。try/except 抓不到,进程直接消失。

后果:

  • 无法降级
  • 无法打日志
  • 进程在 import 阶段就没了,你甚至不知道发生了什么

这就是 ch03 那条铁律「静默降级比崩溃危险一百倍 」的反面教材------这里连降级的机会都没有,是静默死亡:没有 traceback、没有日志、exit code 是个 139,剩下的靠猜。

4.3 解法:纯 Python 等价实现

python 复制代码
# ingest/chunk.py:356
def _rec_split(self, text: str) -> List[str]:
    # 纯 Python 递归切分(不再依赖 langchain_text_splitters):
    # 该包在部分 Python 环境下 import 即段错误,且段错误无法被 try/except 捕获,
    # 会导致整条切片链路崩溃。改用 _py_recursive_split 行为等价实现。
    return _py_recursive_split(text, self.separators, self.child_size, self.child_overlap)
python 复制代码
# ingest/chunk.py:93
def _py_recursive_split(text: str, separators: List[str],
                        chunk_size: int, chunk_overlap: int) -> List[str]:
    """纯 Python 递归切分,行为等价于 langchain 的 RecursiveCharacterTextSplitter。"""

Markdown/HTML 切分器也一并替换

python 复制代码
# ingest/chunk.py:377
def _split_markdown(self, raw: RawDoc) -> List[Chunk]:
    # 纯 Python 按 #~#### 标题切分(不再依赖 langchain 的
    # MarkdownHeaderTextSplitter,避免 import 段错误)
python 复制代码
# ingest/chunk.py:386
def _split_html(self, raw: RawDoc) -> List[Chunk]:
    # 纯 Python 按 <h1>~<h6> 标题切分(不再依赖 langchain 的
    # HTMLHeaderTextSplitter,避免 import 段错误)

注意这里的做法:连根拔起,不是补丁。只替换一个切分器没用------只要包里还有一个 import 路径,段错误就还在。要么全替换,要么别碰。

4.4 递归切分的原理

python 复制代码
def _py_recursive_split(text, separators, chunk_size, chunk_overlap):
    """递归切分:按分隔符优先级从粗到细尝试。

    separators 通常按"从粗到细"排列:
        ["\n\n", "\n", "。", ";", " ", ""]
    1. 用第一个分隔符切
    2. 若某段仍超长 → 用下一个分隔符递归切这一段
    3. 直到用空分隔符(按字符硬切)
    4. 相邻片段按 chunk_overlap 保留重叠
    """

为什么要"递归"? 因为直接用最细的分隔符会把文本切得太碎,而直接用最粗的又切不开长段落。递归保证"能不切就不切,必须切才切"。

4.5 通用教训

遇到第三方库导致的段错误,唯一的可靠办法是绕开它,而不是捕获它。

本项目绕开了两个:

  • langchain_text_splitters(import 段错误)
  • torch 多线程(Windows 下运行时段错误,用限制线程数绕开,见 Ch01)

判据 :如果某个依赖在你的目标环境上不稳定,替换成本 < 排查成本时,直接替换。自研 50 行代码,胜过排查 3 天的段错误。

第四部分小结 · 对照自查

  • 一个事实 ------SIGSEGV 是 OS 信号,不是 Python 异常,try/except 抓不到
  • 一个动作------连根拔起(递归/Markdown/HTML 切分器全替换),不是打补丁
  • 一个原理------递归切分"从粗到细"尝试,保证能不切就不切
  • 一个判据------替换成本 < 排查成本时,直接替换

到这里,目标 4 完成。四条目标全部拿下,下面把这一章的坑摆到一张表上。


踩坑总表

把这一章的坑摆到一张表上------五个,全部在本项目的真实提交里踩过

# 现象 根因 严重度
1 段错误无法捕获 进程在 import 阶段消失,无日志无 traceback SIGSEGV 是 OS 信号,不是 Python 异常 致命
2 Milvus VARCHAR 按字节截断 content 超长报长度超限;英文文档测试完全正常 VARCHAR 按 UTF-8 字节校验,中文 1 字 3 字节,按字符截断必然超标 中等
3 表格被腰斩 参数表后半部分失去表头,模型答不出 固定长度切分落在表格中间,结构被切断 中等
4 PDF 降级静默 扫描件/特殊编码 PDF 走了降级路径,没人知道 降级路径没打日志 中等
5 重复摄入 同一批文档摄入两次,产生重复分片 没有文件指纹,无法增量判定 轻微

第 1 个是"静默死亡"级,第 2 个在中英文混合语料上必现,第 3 个是分块策略的原生缺陷。这张表值得截图存好------外加一条隐藏坑:表格渲染成图之后,表格里的数字就不可文本检索了,这是设计取舍不是 bug,但排查时极易误判(详见进阶题 4)。

对应解法速查

python 复制代码
# 坑 2:按字节截断(不是按字符)
# ingest/store.py:71
def _trunc_bytes(text: str, limit: int = 8192) -> str:
    """按 UTF-8 字节截断(不是按字符)。"""
python 复制代码
# 坑 3:切分时保护表格不被腰斩
# ingest/chunk.py:218
def _segment_tables(text: str) -> List[tuple]:
    """切分时保护表格不被腰斩。"""
python 复制代码
# 坑 4:降级路径(必须打日志)
# ingest/chunk.py:503
def _split_figure_aware(self, raw: RawDoc) -> List[Chunk]:
    """无显式结构时的图感知分块(降级路径)。"""
python 复制代码
# 坑 5:文件指纹(内容 hash + mtime),未变化则跳过
# ingest/fingerprint.py
# ingest/pipeline.py:94
def scan_and_diff(self, files=None, force=False):
    """扫描并对比,只处理有变化的文件。"""

原则 :降级可以,但必须可观测(打日志),否则你不知道哪些文档走了降级路径。


学习目标回顾

对着开头的四个目标,逐个 check:

  • 目标 1 · 分块为什么决定上限------信噪比权衡、切错位置比切错大小更糟、"单独看能不能看懂"的判断标准,你拿下了
  • 目标 2 · 父子切片 ------小块检索大块回答、四条分发路径、md5(路径+内容) 保幂等、父块不参与检索,你拿下了
  • 目标 3 · PDF 表格与图片------保真优先的取舍、字号直方图推断层级、并查集连通分量,你拿下了
  • 目标 4 · 绕开 langchain------SIGSEGV 抓不到、连根拔起而不是打补丁、替换成本 vs 排查成本,你拿下了

四个全过,最后两句话带走:

分块决定了模型最远能看到什么;而"错误的数字比缺失的数字危险",是 ingestion 阶段唯一不能妥协的取舍原则。

留个作业,今天就能做

  1. 拿你自己项目里的一段文档跑一遍分块,随机抽 5 个 chunk 自己读一遍------单独看能看懂吗?能回答什么问题吗?看不懂的就是坏块,回去调切分位置而不是调块大小;
  2. 检查你的 ingestion 有没有降级路径,降级时打日志了吗------没有就补上,这是"静默降级"最容易藏身的地方。

知识点卡片

【知识点】分块是 RAG 的第一性原理

分块决定了检索的上限。 因为:

  • 检索的最小单位是 chunk
  • chunk 语义不完整 → 召回再多也答不对
  • 再好的 reranker 只是在"已有的坏候选"里排序

判断分块好坏的标准

复制代码
单独看这个 chunk,人能看懂吗?能回答什么问题吗?

如果答案是"看不懂"(比如半张表、半段代码),分块就有问题。

【知识点】Small-to-Big(父子切片)

核心矛盾

  • 小块 → 语义集中 → 检索 ,但上下文不全
  • 大块 → 上下文 ,但语义分散 → 检索不准

解法:用小块检索,用大块回答,通过 id 关联。

erlang 复制代码
检索阶段:query → 匹配子块(语义集中,命中精准)
生成阶段:命中子块 → 找到父块 → 把父块喂给 LLM(上下文完整)

变体

变体 做法
父子(本项目) 父=整章,子=细切
句子窗口 匹配句子,返回前后 N 句
自动合并 小块命中后,若同父的命中数 ≥ K 则合并成父块

【知识点】段错误(SIGSEGV)无法捕获

机制 能否 try/except
Python 异常
KeyboardInterrupt ✅(继承 BaseException)
SIGSEGV 段错误 进程直接被 OS 杀死

常见诱因

  • C 扩展库的线程冲突(torch + MKL/OPENBLAS)
  • 内存越界(numpy 某些操作)
  • 库版本与 Python 版本不匹配

应对

  1. 定位到具体是哪个 import / 哪个调用
  2. 绕开它(自研等价实现 / 换库 / 限制线程数)
  3. 不要试图捕获------捕获不了

练习题

基础题

1. 什么是父子切片(Small-to-Big)?它解决了什么矛盾?
答案

核心矛盾

语义集中度 上下文完整性
小块(200 字) 高 → 检索 低 → 回答不全
大块(2000 字) 低 → 检索不准 高 → 回答

小块语义集中,embedding 后向量"指向性"强,容易命中;但 LLM 拿到小块时缺少上下文(比如表格表头在上一块)。

大块相反。

解法:小块检索 + 大块回答,用 id 关联:

erlang 复制代码
检索阶段:query → 匹配子块(350 字,语义集中)
生成阶段:命中子块 → 通过 parent_id 找到父块(整章)→ 喂给 LLM

关键实现细节

  • 父块不参与检索(否则会污染召回结果)
  • 子块携带 parent_idparent_content
  • 检索出口 page_content 返回子块 ,父块放在 metadata["parent_content"] 旁路

本项目真实数据:27 章节 → 25 父块 + 309 子块 = 334 分片。

为什么父块不参与检索? 父块文本长(2000 字),embedding 后会"平均化"------包含多个主题的向量反而什么都不像,检索效果差。这就是"语义分散"。

2. 为什么 PDF 里的表格要渲染成图片,而不是解析成 Markdown 表格?
答案

核心原因:复杂表格几乎必然解析错,而解析错比不解析更危险。

方案 优点 缺点
解析成 Markdown 结构化、文本可检索 合并单元格/跨页表格必然错
渲染成图片 100% 保真 图片内容不可直接文本检索

为什么"解析错更危险"?

arduino 复制代码
真实表格:
| 参数 | 默认值 | 范围 |
| 心跳间隔 | 60s   | 10-3600 |
| 供电电压 | 12V   | 9-36 |

解析错(列错位):
| 参数 | 默认值 |
| 心跳间隔 | 60s |
| 供电电压 | 12V | 9-36 |   ← 列数不对,语义错乱

模型拿到后:"供电电压默认值 12V,范围缺失" 或更糟 ------ 把 9-36 当成别的值

错误的数字比缺失的数字危险得多------用户会照着错误参数去配置设备。

本项目的做法

  1. 识别表格区域(bbox)
  2. 渲染成图片存到 figures/
  3. 正文留 <!-- FIG: xxx.png --> 占位符
  4. 检索命中时,图片路径一起返回,前端渲染出来

代价:表格内容不参与文本检索。缓解办法:

  • 图片可以有 OCR 文本
  • 多模态模型可以直接"看"图
  • 展示给用户看

判据 :如果表格结构简单(规整的二维表),解析成 Markdown 更好;如果复杂(合并单元格、跨页、嵌套),保真优先

3. 为什么本项目要自己实现递归切分,而不用 langchain 的?
答案

因为 langchain_text_splitters 在部分 Python 环境下 import 即段错误(SIGSEGV)。

关键:段错误无法被 try/except 捕获。

python 复制代码
try:
    from langchain_text_splitters import RecursiveCharacterTextSplitter
except Exception:
    fallback()      # ❌ 完全无效,进程在 import 时就消失了

SIGSEGV 是操作系统信号,不是 Python 异常。Python 解释器来不及执行 except 块,进程就被 OS 杀掉了。

后果

  • 无法降级
  • 无法打日志
  • 整条切片链路崩溃

本项目的解法:自研行为等价的纯 Python 实现

python 复制代码
# ingest/chunk.py:356
def _rec_split(self, text: str) -> List[str]:
    # 纯 Python 递归切分(不再依赖 langchain_text_splitters):
    # 该包在部分 Python 环境下 import 即段错误,且段错误无法被 try/except 捕获
    return _py_recursive_split(text, self.separators, self.child_size, self.child_overlap)

连 Markdown/HTML 切分器也一并替换了。

通用教训

遇到第三方库导致的段错误,唯一的可靠办法是绕开它。 替换成本 < 排查成本时,直接替换。 自研 50 行代码,胜过排查 3 天的段错误。

本项目绕开了两个:

  • langchain_text_splitters(import 段错误)
  • torch 多线程(Windows 运行时段错误,限制线程数绕开)

进阶题

4. 你的 RAG 系统对 5000 页技术手册做摄入,发现有 200 页的表格内容检索不到。请给出系统化的排查流程。
参考答案

分层排查(从数据到检索):

第 1 步:确认表格内容有没有进库

bash 复制代码
# 用 CLI 查看摄入状态
python -m ingest.cli status

# 或直接查 Milvus:统计分片数、检查是否有 FIG 占位符
结果 说明 下一步
分片数远少于预期 摄入阶段就丢了 查 loader
分片数正常但表格内容缺失 表格被识别为图,只有占位符 见第 2 步
分片数正常且内容在 是检索问题 跳到第 3 步

第 2 步:确认表格的处理方式

本项目的表格被渲染成图片 ,正文只留 <!-- FIG: xxx.png --> 占位符。

所以表格里的文字本身就不可文本检索 ------ 如果用户的查询依赖表格内的具体数值,就检索不到。

这是设计取舍的结果,不是 bug。

三种补救方案:

方案 做法 成本 效果
A. 表格 OCR 入库 对渲染出的表格图做 OCR,文本一起入库 ✅ 可检索,且保真
B. 结构化表格单独存 简单表格解析成 Markdown 入库 ✅ 规整表格效果好
C. 多模态检索 用多模态 embedding 索引图片 ✅ 最彻底

推荐 A:图已经渲染好了,加一步 OCR 即可,成本最低且兼顾保真与可检索。

第 3 步:如果是纯检索问题

检查:

  • 是否触发了降级路径_split_figure_aware(无结构 PDF 的降级)打的日志
  • 是否被字节截断:Milvus VARCHAR 8192 字节限制,中文 3 字节/字 → 约 2700 字。长表格可能被截断
  • 是否被权限过滤:表格所在文档密级是否对当前用户可见
  • 是否重复摄入失败:文件指纹未变 → 被增量跳过了

第 4 步:验证

python 复制代码
# 写个针对性用例:表格里的具体数值
test_cases = [
    ("供电电压范围是多少", "9-36"),
    ("默认波特率", "9600"),
]
# 看召回的 top-k 里有没有对应分片

经验总结

现象 最可能原因
全部表格检索不到 表格被渲染成图(设计如此),需 OCR
部分表格检索不到 复杂表格走了图片路径,简单表格走了 Markdown 路径
长表格后半部分检索不到 字节截断
特定文档的表格检索不到 该文档走了降级路径

5. 父块 ID 用 md5(章节路径 + 内容)[:16] 生成。这样设计有什么好处?如果改成随机 UUID 会怎样?
参考答案

当前设计的好处:

需求 说明
幂等 同一文档重复摄入,父块 id 不变 → 可做增量更新
唯一 不同章节内容不同 → id 不同
可推导 不需要额外存储映射表,从内容就能算出 id
简短 16 位 hex,够用且不占空间

增量摄入的关键

markdown 复制代码
摄入时:
  1. 计算文件指纹(hash + mtime)
  2. 指纹未变 → 跳过整个文件
  3. 指纹变了 → 重新摄入,用同样的 id 覆盖旧分片

如果 id 每次都变,就无法"覆盖",只能"先删后插",且删除时不知道该删哪些。


改成随机 UUID 的后果:

后果 1:无法增量更新(最严重)

ini 复制代码
第一次摄入:父块 A → id = uuid-1
第二次摄入:父块 A → id = uuid-2    ← 同一个父块,不同 id

结果:库里有两个 A 的副本
     → 检索时重复召回
     → 答案里出现重复内容
     → 存储翻倍

要修复就必须先删旧的,但删除时需要知道"旧 id 是什么" ------ 而 UUID 无法从内容推导,只能额外维护映射表。

后果 2:需要额外的映射存储

sql 复制代码
-- 必须加一张表维护 (文件, 章节) → uuid 的映射
CREATE TABLE chunk_id_map (
    file_path  VARCHAR(512),
    section    VARCHAR(256),
    chunk_id   VARCHAR(64),
    PRIMARY KEY (file_path, section)
);

增加复杂度和一次查询开销。

后果 3:去重失效

本项目的文档去重用身份不用内容(Ch12 会详述):

python 复制代码
# _doc_key 优先级:
pk:{Milvus主键} > {source}#{parent_id}#{chunk_index} > h:{全文md5}

parent_id 稳定 → 去重可靠。 parent_id 随机 → 每次都被当成新文档,去重完全失效。


什么情况下该用 UUID?

场景 用内容 hash 用 UUID
需要幂等/增量
内容会频繁变化 ⚠️ id 跟着变,等价 UUID
需要隐藏内容信息 ❌(hash 可从内容推导)
分布式生成无协调

本项目的场景是"文档摄入后较少变化,需要重复摄入幂等",所以内容 hash 是正确选择。

思考题

6. 本项目选择「表格渲染成图片(保真但不可检索)」而非「解析成 Markdown(可检索但可能错)」。请分析这个取舍,并说明在什么情况下应该反过来选。
参考答案

本项目的取舍逻辑

选择保真的核心理由:错误的数字比缺失的数字危险。

场景:设备技术手册,用户查参数去配置真实设备。

arduino 复制代码
情况 A(保真,不可检索):
  用户问"供电电压范围" → 检索不到 → 系统说"文档未提及"
  → 用户翻手册 → 得到正确答案
  → 代价:多花 1 分钟

情况 B(解析错,可检索):
  用户问"供电电压范围" → 检索到错位的表格 → 回答"12V(范围 60s)"
  → 用户按错误参数配置 → **设备损坏**
  → 代价:可能几万到几十万

当错误的代价远大于不便的代价时,保真优先。


判断框架:三个问题

markdown 复制代码
1. 错误答案的代价是什么?
   ├─ 高(医疗剂量、设备参数、金融数字)→ 保真优先
   └─ 低(一般知识问答)                 → 可检索优先

2. 表格复杂程度如何?
   ├─ 简单规整(二维表、无合并单元格)   → 解析效果本来就好,两者兼得
   └─ 复杂(合并单元格、跨页、嵌套)     → 解析必错,保真优先

3. 有没有补救手段?
   └─ 有 OCR / 多模态检索 → 保真 + OCR = 最优解

应该反过来选(解析成 Markdown)的情况

场景 理由
表格是核心检索对象 比如"价格表查询"系统,用户主要就是查表格数字
表格结构简单规整 解析准确率高,几乎不出错
错误代价低 比如内部知识库的一般性问题
没有 OCR 能力 图片内容完全不可检索,只能靠解析
需要表格数据做计算 结构化数据才能做聚合、对比

典型反例:财务报表问答系统 ------ 表格是主角,必须可检索、可计算,此时应该解析成结构化的(甚至存进数据库用 SQL 查)。


本项目的最优解应该是什么?

「渲染成图 + OCR 文本入库」双轨制

markdown 复制代码
PDF 表格
   ├── 渲染成图片 → 存 figures/ → 前端展示(保真)
   └── OCR 提取文本 → 连同占位符一起入库 → 可检索

检索命中时:
  - 文本片段提供给 LLM(可理解)
  - 图片路径返回给前端(用户可核对原图)

这样既保真又可检索,且用户能看到原图自行核对 ------ 对设备参数这类高风险场景是最优解。

本项目当时没有上 OCR,是一个可以改进的点。 如果重做,这是第一个要补的。


本章小结

收获 内容
一个原理 分块决定检索上限 ------ 判断标准:单独看这个 chunk 人能看懂吗
一个方案 父子切片:小块检索(准)+ 大块回答(全),父块不参与检索
一个取舍 表格渲染成图 ------ 错误的数字比缺失的数字危险
一个算法 PDF 图片抽取 = 并查集 + 连通分量(边算边合并,优于 DFS)
一个铁律 段错误无法 try/except ------ 绕开它,不要试图捕获
一个判据 依赖选型:替换成本 < 排查成本时,直接替换

下一章Ch05 · 向量库与 Milvus 迁移 ------ 分片准备好了,该存哪儿?从 ChromaDB 迁到 Milvus 的全过程,以及为什么"锁版本"比"追新版"重要。


导航 :· 上一篇:工程卫生与文档 · 下一篇:向量库与 Milvus 迁移 →

相关推荐
江润舟1 小时前
万物 | 炼器 从零手搓工业级旋转目标检测网络 · 卷2 —— 计算图、梯度与反向传播(三)
人工智能·深度学习
zhurui_xiaozhuzaizai1 小时前
github上关于节省token的项目一览,上下文压缩,节省token,agent优化skill,热门项目
人工智能·深度学习·github
清 晨1 小时前
跨境社媒内容定位怎么定?用受众、场景和语言建立账号主线
大数据·人工智能·跨境电商·营销策略
长谷深风1111 小时前
Agent执行系统中的身份与版本设计
java·大数据·人工智能·ai·大模型·task·aiagent
后焊加工装家1 小时前
MS-KC716 激光精密焊线机实战应用指南
大数据·人工智能
Mr数据杨1 小时前
MTP RW多标签文本分类实战 从Kaggle练习到文本标注落地
人工智能·数据分析·kaggle竞赛
码农学院1 小时前
外贸独立站GEO实战:用 Python 自动生成多语言 llms.txt 和 Schema 站点地图
人工智能·python·chatgpt·geo·geo优化·ai优化aio
二川bro1 小时前
Open Knowledge Framework:解决AI智能体反复失忆、重复踩坑的知识框架
人工智能
jsjzsl21 小时前
独立自由度框架下核聚变的本体论本质与商业化技术新路径
人工智能·python·算法