Ch04 · 文档摄入与父子切片
覆盖提交 :
85ac3a7(036) ·501e11d(037) ·d1796b2(039) ·d3d460e(041) 代码位置 :GitHub 搜索lingluo1hao / enterprise-ai→ingest/(loaders.py解析 ·chunk.py父子切片 ·pipeline.py主流程),上面每个提交号都能逐条对照改动 难度 :★★★★★ 阶段 :Day 9--12 关键词:结构感知分块 / Small-to-Big / PDF 解析 / 连通分量 / 纯 Python 切分
本章导读
前三章做的是地基 :第 1 章把模型跑起来,第 2 章把提示词管起来,第 3 章把仓库、密钥、文档收拾干净。到这一章为止,系统里还一条知识都没有------模型能跑、会答,但它不知道你的手册里写了什么。
这一章开始进入检索篇,第一件事不是选向量库,是分块。
原因很硬:分块(Chunking)是 RAG 的第一性原理,它决定了检索的上限。 再好的 reranker 也救不了被腰斩的表格------因为 reranker 只能在"已经召回的坏候选"里排序,候选本身是残的,排序再准也没用。
这笔账有多真?项目第一版用最朴素的"固定长度切 600 字",上线当天就暴露三个问题:
- 表格被腰斩 ------ 一个参数表切成两半,两半单独看都是废话
- 章节被打散 ------ 同一节内容散落在三个 chunk,检索回来上下文不全
- 图片全部丢失 ------ 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 表格的解法:渲染成图片
本项目没有硬解析表格结构,而是:
- 识别表格区域(bbox)
- 把该区域渲染成图片
- 在正文里留下
<!-- 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 阶段唯一不能妥协的取舍原则。
留个作业,今天就能做:
- 拿你自己项目里的一段文档跑一遍分块,随机抽 5 个 chunk 自己读一遍------单独看能看懂吗?能回答什么问题吗?看不懂的就是坏块,回去调切分位置而不是调块大小;
- 检查你的 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 版本不匹配
应对:
- 定位到具体是哪个 import / 哪个调用
- 绕开它(自研等价实现 / 换库 / 限制线程数)
- 不要试图捕获------捕获不了
练习题
基础题
1. 什么是父子切片(Small-to-Big)?它解决了什么矛盾?
答案
核心矛盾:
| 语义集中度 | 上下文完整性 | |
|---|---|---|
| 小块(200 字) | 高 → 检索准 | 低 → 回答不全 |
| 大块(2000 字) | 低 → 检索不准 | 高 → 回答全 |
小块语义集中,embedding 后向量"指向性"强,容易命中;但 LLM 拿到小块时缺少上下文(比如表格表头在上一块)。
大块相反。
解法:小块检索 + 大块回答,用 id 关联:
erlang
检索阶段:query → 匹配子块(350 字,语义集中)
生成阶段:命中子块 → 通过 parent_id 找到父块(整章)→ 喂给 LLM
关键实现细节:
- 父块不参与检索(否则会污染召回结果)
- 子块携带
parent_id和parent_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 当成别的值
错误的数字比缺失的数字危险得多------用户会照着错误参数去配置设备。
本项目的做法:
- 识别表格区域(bbox)
- 渲染成图片存到
figures/ - 正文留
<!-- FIG: xxx.png -->占位符 - 检索命中时,图片路径一起返回,前端渲染出来
代价:表格内容不参与文本检索。缓解办法:
- 图片可以有 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 迁移 →