欢迎光临
我们一直在努力

RAG 父子块关联设计:同一张表 + 两步入库,让子块准确找到父块

摘要

在 RAG 系统中,文档切分直接决定检索质量。父子块方案用短子块保证检索命中率、长父块保证回答完整性,两者之间必须建立可靠的关联。本文不讲整个上传流程,只聚焦一个核心问题:父子块在数据库里如何设计和关联,以及设计过程中做了哪些考量。

一、核心问题:父子块如何关联

父子块方案中,一个父块会被切成多个子块。入库时面临两个问题:

问题描述
如何存储 父块和子块分两张表还是一张表?
如何关联 子块入库时,如何知道它属于哪个父块?

关联发生在入库那一刻。切分阶段生成的是临时草稿列表,子块用临时索引 parent_index 标记父块;入库阶段父块先写入获得真实数据库 ID,子块再通过 parent_index 映射表找到这个 ID 并写入自己的 parent_id 字段。下文逐个拆解。

二、存哪里:同一张表还是分两张表?

建表时有两个选择:

  • 方案A:父块一张表,子块一张表,通过外键关联

  • 方案B:父子块同一张表,通过 is_parent 区分类型,通过 parent_id 自关联

本项目采用 方案B,原因有三:

第一,查询更简单。 如果分两张表,要查某个文档的所有分块需要 UNION 两张表;而同一张表只需要一个 SELECT 条件 document_id = ?。

第二,子块找父块更直接。 子块拿到自己的 parent_id,直接在同表里查 id = parent_id 就能拿到父块。如果分两张表,需要跨表 JOIN。

第三,扩展性更好。 未来如果引入更多层级(比如爷爷块、孙块),同一张表加 parent_id 就能无限延伸,分两张表则无法支持。

分块表结构

sql

CREATE TABLE document_chunks (
id INT PRIMARY KEY AUTO_INCREMENT,
document_id INT NOT NULL, — 所属文档
parent_id INT DEFAULT NULL, — 父块ID(子块用)
content TEXT NOT NULL, — 分块文本
is_parent BOOLEAN DEFAULT FALSE, — True=父块, False=子块
FOREIGN KEY (parent_id) REFERENCES document_chunks(id)
);

两个字段决定一切:

记录类型is_parentparent_id
父块 TRUE NULL
子块 FALSE 父块.id

父块没有父,所以 parent_id = NULL。子块必须有父,所以 parent_id 指向某个父块的 id。

三、关联的难点:临时索引如何转为数据库ID

切分阶段生成的草稿列表是临时的,子块只知道自己在列表里属于哪个父块,用 parent_index 记录父块在列表中的位置:

text

列表索引: 0 1 2 3 4 5
内容: [父块A] [子块A1] [子块A2] [父块B] [子块B1] [子块B2]
↑
parent_index=0
↑
parent_index=3

入库时,父块A 写入数据库获得真实 ID(比如 101),子块A1 和 A2 必须把 parent_id 填成 101。

难点在于:子块入库时,父块A 刚刚写入数据库,程序需要一种方式把“列表位置0”映射成“数据库ID 101”。

四、解决方案:两步入库 + 映射表

4.1 第一步:父块先入库,建立映射

先遍历一次草稿列表,只处理 is_parent=True 的记录。

每插入一个父块,数据库生成自增 ID。建立一个 Python 字典,把“列表索引”映射到“数据库ID”。

python

parent_map = {}

for idx, draft in enumerate(drafts):
if draft.is_parent:
chunk = DocumentChunk(
document_id=doc_id,
content=draft.content,
is_parent=True,
parent_id=None
)
session.add(chunk)
session.flush() # 立即获得数据库生成的ID
parent_map[idx] = chunk.id # 列表位置0 → 数据库ID 101

执行完后,parent_map = {0: 101, 3: 102},表示列表位置0的父块对应数据库ID 101,位置3对应102。

4.2 第二步:子块入库,查映射填parent_id

再遍历一次,只处理 is_parent=False 的记录。

子块记录的 parent_index 正好是它在列表里看到父块的位置,用这个去 parent_map 里查,拿到真正的数据库ID填入 parent_id。

python

for idx, draft in enumerate(drafts):
if not draft.is_parent:
chunk = DocumentChunk(
document_id=doc_id,
content=draft.content,
is_parent=False,
parent_id=parent_map.get(draft.parent_index) # 0 → 101
)
session.add(chunk)

session.commit()

五、为什么必须分两步

读者可能会问:为什么不一次性插完?

如果一次性把所有分块(父子混合)插入数据库,子块插入时父块可能还没生成,parent_id 拿不到真实ID。数据库外键约束会直接报错。

那能不能先生成父块的数据库ID再一次性插入? 也不行。SQLAlchemy 的 flush() 会立即生成ID,但此时父块还未提交,子块如果同时插入,需要在同一个事务中拿到父块的ID——这又回到了“父块必须先插入”的问题。

分两步就是为了让父块先拿到数据库ID,子块才能正确引用。

六、完整代码实现(含注释)

6.1 分块草稿结构(chunking.py)

python

@dataclass
class ChunkDraft:
content: str # 分块文本
section: str | None # 所属章节
is_parent: bool # True=父块, False=子块
parent_index: int | None = None # 子块记录父块在列表中的位置

6.2 父子分块主函数(chunking.py)

先按标题分段,再依次切成父块和子块。切分过程中记录每个子块的 parent_index。

python

def split_policy_text(text: str) -> list[ChunkDraft]:
sections = split_by_headings(text) # 按章节标题分段
drafts = []

for section_title, section_body in sections:
# 章节内容切成父块(1200字符)
parent_chunks = window_split(section_body, 1200, 100)

for parent_chunk in parent_chunks:
parent_index = len(drafts) # 父块在列表中的位置

# 添加父块
drafts.append(ChunkDraft(
content=parent_chunk,
section=section_title,
is_parent=True
))

# 父块切成子块(420字符,重叠80)
child_chunks = window_split(parent_chunk, 420, 80)

for child_chunk in child_chunks:
drafts.append(ChunkDraft(
content=child_chunk,
section=section_title,
is_parent=False,
parent_index=parent_index # 关键:记录父块位置
))

return drafts

6.3 入库服务(保存关联)

这个函数实现前文说的两步入库逻辑,代码很短但很关键。

python

def save_chunks_to_db(session, document_id: int, drafts: list[ChunkDraft]) -> int:
# 第一步:父块入库,建立索引→数据库ID映射
parent_map = {}
for idx, draft in enumerate(drafts):
if draft.is_parent:
chunk = DocumentChunk(
document_id=document_id,
content=draft.content,
is_parent=True,
parent_id=None
)
session.add(chunk)
session.flush()
parent_map[idx] = chunk.id

# 第二步:子块入库,通过映射表填充parent_id
for idx, draft in enumerate(drafts):
if not draft.is_parent:
chunk = DocumentChunk(
document_id=document_id,
content=draft.content,
is_parent=False,
parent_id=parent_map.get(draft.parent_index)
)
session.add(chunk)

session.commit()
return sum(1 for d in drafts if not d.is_parent)

七、查询示例:从子块找回父块

关联建好后,查询某个子块对应的父块非常简单:

python

# 假设子块ID是 11
sub_chunk = session.query(DocumentChunk).filter_by(id=11).first()
parent_chunk = session.query(DocumentChunk).filter_by(id=sub_chunk.parent_id).first()

# parent_chunk.content 就是完整的父块全文

同一张表、一个自关联字段,查询成本极低。

八、总结

父子块关联设计的核心可以概括为:

设计点方案原因
存储方式 父子块同一张表 查询简单,扩展性好
区分方式 is_parent 布尔字段 一条记录即可判断类型
关联方式 子块 parent_id → 父块 id 自关联,子块可快速找到父块
入库顺序 父块先入,子块后入 父块先拿到真实ID
关联传递 parent_index → 映射表 → parent_id 将临时索引转换为数据库ID

整个设计的核心思想是:在临时列表里用索引建立关联,入库时通过两步走把索引转换成真实数据库ID。 既保证了外键约束不报错,又让每一条子块都能准确找到自己的父块。

赞(0)
未经允许不得转载:171主机测评 » RAG 父子块关联设计:同一张表 + 两步入库,让子块准确找到父块
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址