RAG 知识入库:Parent–Child 切块与增量同步
RAG 入库的目标不是简单地“把文章转成向量”,而是稳定、低成本地把文档转换成可以持续更新的知识。 文档标准化、切块、指纹、增量匹配、向量生成和数据库更新。
1. 入库流程
Document
↓
Normalize
↓
Fingerprint
├─ 文档没变且 Chunk 存在 → Skip
└─ 文档或配置变化
↓
Section → Parent → Child
↓
content_hash
↓
新旧 Chunk 匹配
┌──────┴──────┐
│ │
Hash 相同 Hash 不同
│ │
保留旧 Row 插入新 Row
复用/更新向量 生成新向量
└──────┬──────┘
↓
删除未匹配旧 Row
↓
PostgreSQL + pgvector
只有 PUBLISHED 内容进入知识库;内容下线后删除对应 Chunk。
2. Parent–Child:小块向量化,大块保留上下文
单一 Chunk 大小很难同时兼顾召回精度和上下文完整度:
| 切块方式 | 优点 | 问题 |
|---|---|---|
| 小 Chunk | 主题集中 | 上下文可能不足 |
| 大 Chunk | 上下文完整 | 一个向量容易混入多个主题 |
因此采用 Parent–Child:
Parent A
├─ Child A1
├─ Child A2
└─ Child A3
- Child 用于生成 Embedding 和参与检索。
- Parent 保存更完整的上下文。
- 一行数据库记录代表一个 Child,同时冗余所属 Parent 的信息。
当前切块方式:
| 层级 | 规则 |
|---|---|
| Section | 按 Markdown 标题划分 |
| Parent | 约 2400 个字符 |
| Child | 约 800 个字符 |
| Overlap | 约 120 个字符 |
边界优先选择换行,其次是句号,最后才按长度硬切。Overlap 用来减少信息被切断造成的语义损失。
3. 入库数据结构
contents
| 字段 | 作用 |
|---|---|
body | Markdown 正文 |
knowledge_hash | 文档 Fingerprint |
status | DRAFT、PUBLISHED 或 OFFLINE |
updated_at 只能说明记录被修改过,不能说明真正参与 RAG 的输入发生了变化,所以不能单独作为重新入库依据。
knowledge_chunks
一行代表一个 Child,同时冗余保存 Parent 信息:
| 字段 | 作用 |
|---|---|
id | 数据库内部主键 |
content_id | 所属文档 |
chunk_index | Child 在整篇文档中的顺序 |
text | Child 文本 |
content_hash | Child 内容 Hash |
embedding | Child 向量 |
embedding_model | 向量使用的模型 |
parent_uid | Child 所属 Parent 的分组 ID |
parent_index | Parent 在整篇文档中的顺序 |
parent_text | Parent 完整文本 |
section_path | Markdown 章节路径 |
冗余保存 parent_text 是用少量存储换简单读取:命中 Child 后可以直接得到 Parent 上下文,不需要额外维护 Parent 表。
4. Fingerprint:文档级快路径
同步前先计算整篇文档的 Fingerprint:
fingerprint = sha256(
normalized_text
+ chunk_config
+ embedding_model
+ embedding_dimension
+ pipeline_version
)
它包含真正会影响入库结果的内容和配置:
- 标题、摘要和正文。
- Parent/Child Size 与 Overlap。
- Embedding 模型与维度。
- 入库流程版本。
快速跳过必须同时满足:
Fingerprint 相同 AND 数据库中 Chunk 数量大于 0
检查 Chunk 数量是为了处理“文章下线后 Chunk 已被删除,但旧 Fingerprint 还在”的情况。
Fingerprint 管整篇文档。它相同就不切块、不调用 Embedding;它变化后,才进入 Chunk 级增量匹配。
5. Normalize:先统一文本再计算 Hash
计算 Hash 前需要统一文本,例如:
\r\n → \n
删除行尾空格
清理不必要的空白
Hash 判断的是标准化后的文本是否完全一致,不理解语义:
“部署到 ECS” ≠ “部署至 ECS”
两句话意思接近,但 Hash 不同,会作为变化内容重新向量化。
6. content_hash:Chunk 级增量匹配
每个 Child 计算:
content_hash = sha256(child_text)
content_hash 只包含 Child 文本,不包含模型、维度、位置或 Parent 信息。
向量复用需要同时满足:
content_hash 相同
+ embedding_model 相同
+ embedding 维度相同
处理规则:
Hash 相同 + 模型配置相同
→ 保留旧 Row
→ 复用旧 Vector
Hash 相同 + 模型配置变化
→ 保留旧 Row
→ 重新生成 Vector
Hash 不同
→ 插入新 Row
→ 生成新 Vector
未匹配的旧 Row
→ 删除
系统不会根据最近位置,让完全不同的新内容覆盖旧 Row。
content_hash 不是唯一 ID
一篇文章中可能重复出现相同文本,所以匹配结构是:
Hash → 一组候选旧 Chunk
而不是 Hash → 唯一 Chunk。匹配时按出现次数依次消费旧候选,确保重复文本也能正确配对。
7. parent_uid、位置和章节路径
parent_uid
Parent 使用确定性的 UUID5:
parent_uid = uuid5(
namespace,
f"{content_id}:{sha256(parent_text)}:{occurrence}"
)
- Parent 文本相同,生成的 UID 相同。
occurrence表示相同 Parent 文本在文章中第几次出现,用于区分重复 Parent。parent_uid用于把兄弟 Child 归到同一个 Parent,不负责向量复用。
parent_index 与 chunk_index
parent_index = Parent 在整篇文章中的顺序
chunk_index = Child 在整篇文章中的顺序
section_path
section_path 保存 Markdown 标题形成的面包屑:
部署 / 阿里云 / ECS
它只用于展示知识来源,不参与 Embedding、向量匹配或 Parent 去重。
章节移动为什么可以复用向量
如果章节只是调整顺序或更换上级章节,而 Parent/Child 文本没有变化:
parent_uid 不变
content_hash 不变
→ 保留旧 Row
→ 复用旧 Vector
→ 更新 parent_index、chunk_index、section_path
性能收益来自 content_hash 命中后不再调用 Embedding。parent_uid 保持分组,section_path 更新展示位置。
如果章节自身标题或正文变化,Parent/Child 文本也会变化,需要重新生成对应向量。
8. Chunk 重排与事务
数据库存在唯一约束:
UNIQUE(content_id, chunk_index)
当旧顺序 A → 0、B → 1 调整为 B → 0、A → 1 时,直接更新会发生索引冲突。
处理方式:
- 将保留的旧 Row 临时移动到负索引。
- 删除未匹配的旧 Row。
- 写入最终位置和新增 Row。
- 在同一事务中提交。
整个过程要么全部成功,要么全部回滚,避免知识库处于半更新状态。
9. 增量同步示例
旧 Chunk 为 1 2 3 4 5,新 Chunk 为 1 2 3 5 7:
| Chunk | 变化 | 处理 |
|---|---|---|
| 1、2、3 | 内容没变 | 保留 Row,复用 Vector |
| 5 | 位置改变,内容没变 | 保留 Row,复用 Vector,更新位置 |
| 7 | 新内容 | 插入 Row,生成 Vector |
| 4 | 已删除 | 删除旧 Row |
最终仍有 5 个 Chunk,但只需生成 1 个新 Embedding。
10. 费用与可观测性
Embedding 是入库中主要的外部付费调用。服务端日志记录:
provider
model
item_count
input_chars
input_tokens
total_tokens
dimensions
duration_ms
provider_request_id
日志使用 billable=true 标识可能计费的请求,但不打印 API Key、文章正文或完整输入。
需要重点观察:
- Fingerprint 跳过了多少次整篇同步。
- 复用了多少旧向量。
- 新生成了多少向量。
- 模型请求 Token 和耗时。
- 同步失败发生在哪一批请求。
11. 后续优化方向
优先级较高:
- Fingerprint 命中时真正做到数据库零写入。
- 只更新实际变化的 Row 字段。
- 对模型的 429、5xx 和超时增加有限重试。
- 防止同一文章被并发同步。
数据量明显增大后再考虑全局向量缓存和异步任务队列。
总结
Fingerprint → 文档级跳过
content_hash → Child 级增量匹配
Parent–Child → 小块向量化,大块保留上下文
相同内容 → 保留 Row,复用向量
不同内容 → 新增 Row,删除旧 Row
事务 → 保证整批同步一致性
入库优化的核心不是增加更多 ID,而是减少不必要的切块、Embedding 和数据库写入,同时保证每次同步结果完整一致。