RAG 文档切分中父子块关联的设计与实现

一、为什么需要父子块

在 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)

关键字段说明:

字段类型作用
contentstr块的文本内容
sectionstr | None所属章节标题,如 “第一章 总则”
pageint | None来源页码(PDF 才有)
is_parentboolTrue = 父块,False = 子块
parent_indexint | None子块指向父块在 drafts 列表中的下标
metadatadict扩展元数据,当前为空字典预留

这里最关键的设计是 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 字段转换方式
(无)idUUID 自动生成 (new_id)
(无)document_id来自 PolicyDocument.id 外键
parent_index (int)parent_id (str UUID)通过 local_rows 字典转换
(无)ordinal全局递增计数器
contentcontent直接赋值
is_parentis_parent直接赋值
metadata (dict)metadata_json (JSON)直接赋值

核心变化是 parent_index(内存下标)变成了 parent_id(数据库 UUID 外键)。parent_id自引用外键,指向同一张 document_chunks 表中的另一条记录,并且设置了 ON DELETE CASCADE,父块被删除时子块自动级联删除。

2.3 关联关系全景

整个系统存在三层关联关系:

  1. PolicyDocument 1:N DocumentChunk — 一个文档包含多个块(通过 document_id 外键)
  2. DocumentChunk 1:N DocumentChunk — 一个父块包含多个子块(通过 parent_id 自引用外键)
  3. 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

父子块生成的关键逻辑:

  1. 遍历每个章节 (sec_title, sec_body)
  2. 对章节正文调用 _windows(sec_body, 1200, 100) 切出父块
  3. 记录此时 drafts 列表长度作为 parent_index — 这就是父块在列表中的下标
  4. 生成父块 ChunkDraftis_parent=True,不设 parent_index
  5. 对每个父块再调用 _windows(parent, 420, 80) 切出子块
  6. 生成子块 ChunkDraftis_parent=Falseparent_index 指向父块下标

关键行: parent_index = len(drafts) 这一行是整个关联设计的核心。它在父块被追加之前记录当前列表长度,这个值恰好就是父块即将被插入的位置。后续所有子块都通过这个下标找到自己的父块。

3.4 切分参数选择

参数说明
parent_size1200父块窗口大小,保证完整语义
parent_overlap100父块重叠,避免跨块丢信息
child_size420子块窗口大小,适合精准检索
child_overlap80子块重叠,避免截断关键句

父块 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 是 ChunkDraftdrafts 列表中的下标(即 parent_index),value 是已经 flush 到数据库并获得 UUID 的 DocumentChunk 对象。

4.2 关联建立的完整流程

  1. split_policy_text 返回 drafts 列表,其中子块携带 parent_index
  2. 遍历 drafts,为每个 ChunkDraft 创建 DocumentChunk 对象
  3. session.flush() 将记录写入数据库,触发 UUID 生成
  4. index -> DocumentChunk 存入 local_rows 字典
  5. 如果当前块是子块(parent_index is not None),通过 local_rows[parent_index].id 拿到父块的 UUID
  6. 将父块 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 检索流程

  1. 用户提问,Query 被 Embedding 模型转为向量
  2. 在 Milvus 中搜索与 Query 向量最相似的 Top-K 子块
  3. 可选:使用 Reranker 对 Top-K 结果精排,取 Top-4
  4. 通过子块的 parent_id 查询 MySQL,获取对应的父块内容
  5. 将父块内容(而非子块内容)作为上下文拼入 LLM Prompt
  6. 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 字典将下标转为 UUIDDocumentChunk.parent_id (str UUID)
存储阶段自引用外键 + ON DELETE CASCADEparent_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 生成答案的质量。本文的实现在切分解耦、外键级联、检索回溯等方面做出了合理的工程取舍,同时也有进一步优化的空间。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值