一、为什么需要父子块
在 RAG (Retrieval-Augmented Generation) 系统中,文档切分是决定检索质量的关键环节。切得太碎,单个块缺乏完整语义;切得太长,向量检索的精度下降,且容易超出 Embedding 模型的 token 限制。
父子块 (Parent-Child Chunk) 策略是一种平衡方案:用小块做精准检索,用大块提供完整上下文。具体来说,将文档先切成较大的父块 (Parent Chunk),再把每个父块切成更小的子块 (Child Chunk)。检索时对子块做向量搜索,命中后回溯到对应的父块,将父块的内容作为上下文喂给 LLM。
核心思想: 子块负责"精准命中",父块负责"提供上下文"。两者通过关联字段绑定。
本文结合实际项目代码,详细讲解父子块关联关系是如何设计、切分、入库和检索的。
二、数据模型设计
2.1 切分阶段:ChunkDraft
切分阶段使用一个轻量数据类 ChunkDraft 来表示尚未入库的块。它定义在 chunking.py 中,使用 Python 的 dataclass 装饰器:
@dataclass
class ChunkDraft:
content: str
section: str | None
page: int | None
is_parent: bool
parent_index: int | None = None
metadata: dict = field(default_factory=dict)
关键字段说明:
| 字段 | 类型 | 作用 |
|---|---|---|
content | str | 块的文本内容 |
section | str | None | 所属章节标题,如 “第一章 总则” |
page | int | None | 来源页码(PDF 才有) |
is_parent | bool | True = 父块,False = 子块 |
parent_index | int | None | 子块指向父块在 drafts 列表中的下标 |
metadata | dict | 扩展元数据,当前为空字典预留 |
这里最关键的设计是 parent_index 字段。它不是数据库中的外键,而是一个临时的内存下标,指向同一个 drafts 列表中父块的位置。这个设计避免了切分阶段就依赖数据库 ID,让切分逻辑与持久化解耦。
2.2 持久化阶段:DocumentChunk
入库阶段使用 SQLAlchemy ORM 模型 DocumentChunk,定义在 models.py 中:
class DocumentChunk(TimestampMixin, Base):
__tablename__ = "document_chunks"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
document_id: Mapped[str] = mapped_column(
ForeignKey("policy_documents.id", ondelete="CASCADE"), index=True
)
parent_id: Mapped[str | None] = mapped_column(
ForeignKey("document_chunks.id", ondelete="CASCADE"), index=True
)
ordinal: Mapped[int] = mapped_column(Integer)
section: Mapped[str | None] = mapped_column(String(255))
page: Mapped[int | None] = mapped_column(Integer)
content: Mapped[str] = mapped_column(Text)
is_parent: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
token_count: Mapped[int] = mapped_column(Integer, default=0)
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
与 ChunkDraft 相比,DocumentChunk 增加了以下持久化字段:
| ChunkDraft 字段 | DocumentChunk 字段 | 转换方式 |
|---|---|---|
| (无) | id | UUID 自动生成 (new_id) |
| (无) | document_id | 来自 PolicyDocument.id 外键 |
parent_index (int) | parent_id (str UUID) | 通过 local_rows 字典转换 |
| (无) | ordinal | 全局递增计数器 |
content | content | 直接赋值 |
is_parent | is_parent | 直接赋值 |
metadata (dict) | metadata_json (JSON) | 直接赋值 |
核心变化是 parent_index(内存下标)变成了 parent_id(数据库 UUID 外键)。parent_id 是自引用外键,指向同一张 document_chunks 表中的另一条记录,并且设置了 ON DELETE CASCADE,父块被删除时子块自动级联删除。
2.3 关联关系全景
整个系统存在三层关联关系:
- PolicyDocument 1:N DocumentChunk — 一个文档包含多个块(通过
document_id外键) - DocumentChunk 1:N DocumentChunk — 一个父块包含多个子块(通过
parent_id自引用外键) - DocumentChunk 1:1 Milvus Record — 每个子块在 Milvus 中有一条向量记录(通过
chunk_id关联)
三、切分逻辑详解
3.1 章节识别:HEADING_PATTERN
切分的第一步是识别政策文档中的章节标题。系统使用一个正则表达式来匹配常见的政策标题格式:
HEADING_PATTERN = re.compile(
r"(?m)^([第][一二三四五六七八九十百]+[章节条]\s*.*"
r"|[一二三四五六七八九十]+[、.]\s*.*"
r"|\d+[.、]\s*.*)$"
)
这个正则匹配三类标题:
- 第X章/节/条 — 如 “第一章 总则”、“第三条 适用范围”
- 中文数字编号 — 如 “一、概述”、“二. 适用范围”
- 阿拉伯数字编号 — 如 “1. 总则”、“2、适用范围”
通过 finditer 找到所有标题后,按照标题位置将文本切分为多个 (标题, 正文) 的章节片段。如果文本开头不是标题,标题之前的前置文本会被当作无标题章节处理。
3.2 窗口切分:_windows 函数
_windows 是核心切分函数,负责将一段文本按指定窗口大小和重叠量切分为多个片段。它被调用两次:第一次用大窗口切父块,第二次用小窗口切子块。
def _windows(text: str, size: int, overlap: int) -> list[str]:
text = text.strip()
if not text:
return []
chunks: list[str] = []
start = 0
while start < len(text):
end = min(len(text), start + size)
if end < len(text):
punctuation = max(text.rfind(mark, start, end) for mark in '。;!?\n')
if punctuation > start + size // 2:
end = punctuation + 1
chunks.append(text[start:end].strip())
if end >= len(text):
break
start = max(start + 1, end - overlap)
return [chunk for chunk in chunks if chunk]
该函数有一个标点感知截断机制:在 [start, end) 范围内从右往左查找句末标点(。;!?\n),如果找到的标点位置超过窗口中点(start + size // 2),就在该标点处截断,避免把句子切碎。
3.3 父子块生成:split_policy_text
split_policy_text 是切分的编排函数,将章节识别和窗口切分组合起来,生成带父子关系的 ChunkDraft 列表:
def split_policy_text(text, page=None, parent_size=1200, child_size=420, child_overlap=80):
matches = list(HEADING_PATTERN.finditer(text))
# ... 按标题切分章节 ...
drafts: list[ChunkDraft] = []
for sec_title, sec_body in sections:
for draft in _windows(sec_body, parent_size, 100):
parent_index = len(drafts)
# 生成父块
drafts.append(ChunkDraft(
content=draft, section=sec_title, page=page,
is_parent=True
))
# 生成子块
for child_draft in _windows(draft, child_size, child_overlap):
drafts.append(ChunkDraft(
content=child_draft, section=sec_title, page=page,
is_parent=False, parent_index=parent_index
))
return drafts
父子块生成的关键逻辑:
- 遍历每个章节
(sec_title, sec_body) - 对章节正文调用
_windows(sec_body, 1200, 100)切出父块 - 记录此时
drafts列表长度作为parent_index— 这就是父块在列表中的下标 - 生成父块
ChunkDraft,is_parent=True,不设parent_index - 对每个父块再调用
_windows(parent, 420, 80)切出子块 - 生成子块
ChunkDraft,is_parent=False,parent_index指向父块下标
关键行:
parent_index = len(drafts)这一行是整个关联设计的核心。它在父块被追加之前记录当前列表长度,这个值恰好就是父块即将被插入的位置。后续所有子块都通过这个下标找到自己的父块。
3.4 切分参数选择
| 参数 | 值 | 说明 |
|---|---|---|
parent_size | 1200 | 父块窗口大小,保证完整语义 |
parent_overlap | 100 | 父块重叠,避免跨块丢信息 |
child_size | 420 | 子块窗口大小,适合精准检索 |
child_overlap | 80 | 子块重叠,避免截断关键句 |
父块 1200 字保留了段落的完整上下文,子块 420 字则聚焦于单个要点,两者各有分工。
四、入库时的关联建立
切分完成后,ChunkDraft 列表被传入 ingest_upload 函数,在这里被转换为 DocumentChunk ORM 对象并写入 MySQL。父子关联从内存下标转换为数据库外键的过程就在这里完成。
4.1 local_rows 字典:桥接内存下标与数据库 UUID
ingest_upload 函数中定义了一个关键变量 local_rows:
chunk_rows: list[DocumentChunk] = []
ordinal = 0
for page, text in extract_pages(uploadPath):
drafts = split_policy_text(text, page=page)
local_rows: dict[int, DocumentChunk] = {}
for index, draft in enumerate(drafts):
chunk = DocumentChunk(
document_id=document.id,
parent_id=None,
ordinal=ordinal,
section=draft.section,
page=draft.page,
content=draft.content,
is_parent=draft.is_parent,
token_count=len(draft.content),
metadata_json=draft.metadata,
)
chunk_rows.append(chunk)
session.add(chunk)
session.flush() # 刷新获取数据库生成的 UUID
local_rows[index] = chunk
# 关键:将 parent_index 转换为 parent_id
if draft.parent_index is not None:
chunk.parent_id = local_rows[draft.parent_index].id
ordinal += 1
local_rows 是一个字典,key 是 ChunkDraft 在 drafts 列表中的下标(即 parent_index),value 是已经 flush 到数据库并获得 UUID 的 DocumentChunk 对象。
4.2 关联建立的完整流程
split_policy_text返回drafts列表,其中子块携带parent_index- 遍历
drafts,为每个ChunkDraft创建DocumentChunk对象 session.flush()将记录写入数据库,触发 UUID 生成- 将
index -> DocumentChunk存入local_rows字典 - 如果当前块是子块(
parent_index is not None),通过local_rows[parent_index].id拿到父块的 UUID - 将父块 UUID 赋值给子块的
parent_id字段,建立数据库外键关联
注意:
local_rows是按页(page)重建的。每处理新的一页时local_rows被清空,这意味着父子关联只在同一页内有效。如果父块跨页,这个设计可能丢失关联。
4.3 入库后的状态更新
所有块写入数据库后,系统统计子块数量并更新文档状态:
chunk_num = len([chunk for chunk in chunk_rows if not chunk.is_parent])
document.chunk_count = chunk_num
document.status = DocumentStatus.ready
session.commit()
只统计子块数量(is_parent=False)作为 chunk_count,因为父块不参与向量检索,检索时使用的是子块。
五、检索时如何利用父子关系
父子块关联在检索阶段的价值体现在"子块命中,父块提供上下文"的策略上。
5.1 检索流程
- 用户提问,Query 被 Embedding 模型转为向量
- 在 Milvus 中搜索与 Query 向量最相似的 Top-K 子块
- 可选:使用 Reranker 对 Top-K 结果精排,取 Top-4
- 通过子块的
parent_id查询 MySQL,获取对应的父块内容 - 将父块内容(而非子块内容)作为上下文拼入 LLM Prompt
- LLM 基于完整上下文生成最终回答
5.2 为什么用父块做上下文
子块只有 420 字,可能只包含一个要点,缺乏前后文。例如子块内容是"第三条 人工智能技术应用应当遵循安全可控原则",如果不提供上下文,LLM 不知道这是哪部法规的第三条。
而父块有 1200 字,包含完整的段落甚至整个章节,能提供更丰富的背景信息。通过 parent_id 回溯到父块,就实现了"小块检索、大块生成"的效果。
5.3 与 Milvus 的配合
在 Milvus 中,只存储子块的向量和基本元数据(document_id, chunk_id, section)。父块不向量化、不入 Milvus,仅存在 MySQL 中。这样设计的原因:
- 子块更小更聚焦,向量检索精度更高
- 减少 Milvus 存储量(不存冗余的父块向量)
- 父块通过 MySQL 外键随时可查,不需要向量检索
六、设计总结与改进方向
6.1 当前设计总结
| 环节 | 机制 | 关键字段 |
|---|---|---|
| 切分阶段 | parent_index 记录父块在 drafts 列表中的下标 | ChunkDraft.parent_index (int) |
| 入库阶段 | local_rows 字典将下标转为 UUID | DocumentChunk.parent_id (str UUID) |
| 存储阶段 | 自引用外键 + ON DELETE CASCADE | parent_id -> document_chunks.id |
| 检索阶段 | 子块向量检索 -> parent_id 回溯父块 | Milvus + MySQL 联合查询 |
6.2 可能的改进方向
- 跨页关联: 当前
local_rows按页重建,跨页的父子块会丢失关联。可以考虑全局local_rows或在切分阶段避免跨页切父块。 - token_count 精确计算: 当前使用
len(content)粗略估算,应替换为 tokenizer 精确计算,尤其使用 bge 模型时。 - metadata 扩展: 当前
metadata字段为空,可加入来源文件名、章节层级、关键词等辅助检索信息。 - 增量更新: 当前删除文档会级联删除所有块,但 Milvus 中的向量需要同步清理,否则会产生孤儿向量。
父子块关联是 RAG 系统中一个看似简单但影响深远的设计。好的关联设计让检索更精准、上下文更完整,最终提升 LLM 生成答案的质量。本文的实现在切分解耦、外键级联、检索回溯等方面做出了合理的工程取舍,同时也有进一步优化的空间。

489

被折叠的 条评论
为什么被折叠?



