跳到主要内容

WeKnora 自适应分片机制:三层策略、文档画像与父子分块

· 阅读需 14 分钟
一介布衣
全栈开发者

做 RAG 的人迟早会撞到一堵墙:检索命中率上不去,答案总是缺上下文,或者命中的块跟问题根本不相关。排查到最后,问题往往不在模型、不在向量库,而在最前面那一步——分片(chunking)。

大多数项目的分片逻辑简单粗暴:按固定字符数切,加个 overlap 完事。这在一篇结构规整的文章上凑合能用,但真实世界的文档千奇百怪——有带规范标题的技术手册,有 OCR 出来的、连标题都没有的扫描件,有 FAQ 那样的原子条目,也有长篇叙事报告。用同一把尺子去量所有文档,注定顾此失彼。

WeKnora 的分片器(Go 侧 internal/infrastructure/chunker 包)给出的答案是一套自适应架构。这是系列第二篇,我按源码把它逐层拆开。

一刀切为什么不行​

先说清楚问题。固定长度分片有三个老大难:

  • 切断语义单元:一个表格切到一半、一段代码从中间断开、一个公式被劈成两半,检索命中了也没法用。
  • 无视文档结构:Markdown 的标题层级本来是天然的分段信号,固定切分完全浪费掉,切出来的块丢失了「这段属于哪一章哪一节」的语境。
  • 粒度两难:块太小主题聚焦但上下文不足,块太大上下文够但主题发散、还容易超出 embedding 模型的 token 上限。

WeKnora 的核心思路是:先诊断文档长什么样,再决定用什么算法切;切完还要质检,不合格就降级换一个算法重试。这套「文档画像 → 分层策略 → 结果校验 → 逐级回退」的机制,是整个分片器的骨架。

三个 Tier 与回退链​

公开入口是 chunker.Split(text, cfg) 和 chunker.SplitWithDiagnostics(strategy.go)。配置里的 strategy 字段决定尝试哪条 Tier 链:

Strategy 值尝试链说明
auto由画像器决定,可能是 [heading, heuristic, legacy] 的子序列推荐值,按文档结构自动选
heading[heading, legacy]强制标题分块,失败回退
heuristic[heuristic, legacy]强制启发式分块
recursive[legacy]recursive 是 legacy 的公开别名
legacy / 空[legacy]历史递归分块器,向后兼容默认值

三个 Tier 分别是:Tier 1 标题感知、Tier 2 启发式边界、Tier 3 递归兜底。关键规则是:每个 Tier 切完都要过一遍 Validator(validator.go)才能被采纳,否则链条前进到下一 Tier;而 legacy 是保底层——即使它也没通过校验,仍然返回它的结果,永不返回空。

这个「永不返回空」的设计很务实:再差也要给用户一个能用的结果,同时把降级原因记录进诊断信息,方便排查。

flowchart TD
A["输入文本 + SplitterConfig"] --> B["ensureDefaults<br/>512/80 兜底, TokenLimit 换算, overlap 钳制"]
B --> C{"strategy ?"}
C -->|"legacy/recursive/空"| L["Tier 3: 递归分块"]
C -->|"heading"| H1["Tier 1: 标题分块"]
C -->|"heuristic"| H2["Tier 2: 启发式分块"]
C -->|"auto"| P["ProfileDocument 单遍画像"]
P --> S{"SelectStrategy"}
S -->|"标题≥3 且密度>0.005"| H1
S -->|"启发式标记≥5 或有换页符/章节标记"| H2
S -->|"无结构信号"| L
H1 --> V1{"Validator 通过?"}
V1 -->|"否"| H2X{"链上还有 heuristic?"}
H2X -->|"是"| H2
H2X -->|"否"| L
H2 --> V2{"Validator 通过?"}
V2 -->|"否"| L
L --> V3{"Validator 通过?"}
V3 -->|"否(仍返回 legacy 结果)"| OUT
V1 -->|"是"| OUT["返回 []Chunk"]
V2 -->|"是"| OUT
V3 -->|"是"| OUT

文档画像:先诊断,再开方​

auto 模式的第一步是 ProfileDocument(text)(profiler.go),它单遍扫描全文,产出一个 DocProfile,里面塞满了结构信号:总字符/行数、行长均值方差、Markdown 各级标题计数、编号小节数、全大写短行数、连续空行块、换页符 \f 数量、水平分隔线数、德/英/中章节标记数、页脚行数、是否含表格/代码、代码占比,以及语言检测(采样前 4096 字节,按 CJK/拉丁比例判 zh/de/en/mixed)。

拿到画像后,SelectStrategy(profile) 组装尝试链,逻辑很直白:

// Tier 1 候选:有 Markdown 标题结构
if p.MdHeadingTotal >= 3 && p.HeadingDensity() > 0.005 && p.DominantHeadingLevel() > 0 {
chain = append(chain, TierHeading)
}
// Tier 2 候选:有启发式边界线索
if p.HeuristicMarkerTotal() >= 5 || p.FormFeedCount > 0 ||
p.GermanChapterCount+p.EnglishChapterCount+p.ChineseChapterCount > 0 {
chain = append(chain, TierHeuristic)
}
chain = append(chain, TierLegacy) // 永远兜底

这里有个精巧的细节是 DominantHeadingLevel(主分割层级)的选法:优先取「出现 ≥3 次的最浅层级」,因为那才是文档真正的结构骨架;否则退而取最深的出现过的层级。这避免了把偶尔出现的一级标题误当成主结构。

Tier 1:标题感知分块​

适用:有规范 Markdown 标题结构的文档(技术文档、导出的 Word、带书签的 PDF)。核心实现在 heading_splitter.go + heading_hierarchy.go。

算法分四步:

  1. 以 DominantHeadingLevel 为主层级,findHeadingBoundaries 找出所有 level <= primaryLevel 的标题行作为段边界(会跳过 fenced code 里的伪标题,避免把代码里的 # 注释当标题);边界太少时直接回退。
  2. HeadingHierarchy 维护一个 6 层标题栈:压入一个 level-N 标题会弹出所有 ≥N 的层,BreadcrumbWithHashes() 输出形如 "# 第一章\n## 1.2 节" 的面包屑。
  3. 每个 section 判断:如果「面包屑长度 + 段长 ≤ ChunkSize」,整段作为一个 Chunk,面包屑放进 ContextHeader(注意,不进 Content);如果超长,段内交给 Tier 3 的 SplitText 二次切分,每个子块通过偏移量拿到「该位置生效的最深标题路径」作为自己的 ContextHeader。
  4. coalesceTinyChunks 把相邻的、小于 ChunkSize/2 且共享标题前缀、位置连续的小块合并——这样 FAQ 式的短小节文档不会因为「碎块太多」整体跌落到 legacy。

这里有一条贯穿始终的位置不变式:End - Start == utf8.RuneCountInString(Content) 恒成立(面包屑不算进 Content)。文档还原、UI 高亮全靠它,一旦破坏,点击检索结果跳转原文就会错位。

Tier 2:启发式边界分块​

适用:没有 Markdown 标题、但有可识别结构线索的文档(OCR 出的 PDF、纯文本手册、扫描版书籍)。实现在 heuristic_splitter.go + patterns.go。

它先扫描全部候选边界,同一个偏移只保留优先级最高的那个:

边界类型优先级
换页符 \f100
编号小节(1.2.3 标题、IV. Results)90
章节标记(Chapter 3 / Kapitel 2 / 第一章)85
全大写短行标题70
视觉分隔线(---、===、***)60
页脚(Page 3 of 10 / 页码 3)50
连续 ≥3 个换行40

拿到边界后:先用 dropBoundsInsideSpans 丢弃落在受保护区间(表格/代码/公式)内部的边界;再做贪心装箱,沿边界累积内容,累计超过 ChunkSize 且已有足够内容时落一个 Chunk;两边界之间的超大块递归交给 Tier 3;最后 applyOverlapAligned 在块尾窗口内优先吸附到最近的语义边界(其次是换行),避免下一块从词中间开始。

章节标记还会根据配置的 languages 提示做筛选——如果明确是中文文档,就不会去匹配德文的 Kapitel。

Tier 3:递归分块(保底引擎)​

这是从 Python docreader/splitter/splitter.py 移植过来的基础实现(splitter.go),既是兜底层,也是前两个 Tier 做「段内二次切分」时调用的引擎。三步走:

Step 1 — 识别受保护区间(protectedSpans)。这些内容绝不从中间切开:

var protectedPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?s)\$\$.*?\$\$`), // LaTeX 块级公式
regexp.MustCompile(`!\[[^\]\n]{0,200}\]\([^)\n]{1,500}\)`), // Markdown 图片
regexp.MustCompile(`\[[^\]\n]{1,200}\]\([^)\n]{1,500}\)`), // Markdown 链接
/* 表头 + 分隔行 */ /* 表格数据行 */ // Markdown 表格
regexp.MustCompile("(?s)```(?:\\w+)?[\\r\\n].*?```"), // fenced 代码块
regexp.MustCompile("`[^`\\r\\n]+`"), // 行内代码
}

图片和链接的匹配被限定在单行内、文字 ≤200 字符、地址 ≤500 字符。这个限制很关键:OCR 残留的孤立 [ 不会跟远处的 ]( 错误配对,把整段正文误判成一个不可切分的保护区。超过 7500 rune 的超大保护区(巨型表格/代码块)会被强制在换行或空格处切开,避免超出 embedding API 限制。

Step 2 — 递归分隔(splitBySeparators):按分隔符优先级切(默认 \n\n → \n → 。),仍超长的片段递归用下一级分隔符。

Step 3 — 合并与重叠(mergeUnits):把小单元装配成块,落块时用 computeOverlap 从当前块尾部取一段作为下一块开头。

computeOverlap 取的是语义后缀而不是定长字符切片,这点做得很细:ChunkOverlap 是硬上限而非目标值;边界优先级是段落分隔 → 换行 → 句末标点(英文句号还要求后面跟空格,避免把 3.14、v1.2 切开);窗口内找不到合法语义边界时干脆不保留重叠,也不从词中间截断。

Validator:五条拒绝规则​

每个 Tier 的产物都要过 ValidateChunks(validator.go)。它用五条规则判断这批块是否可用,任一命中就拒绝并记录原因:

规则拒绝原因
无输出no chunks produced
文档超过 2*chunkSize 却只产出 1 块single chunk for large document
非末尾的「小于 50 字符」小块超过总数 1/4 且多于 2 个too many tiny chunks
最大块不足 chunkSize/4(过度碎片化)all chunks far below target size
最大块超过 2*chunkSize(无视预算)chunk exceeds 2x target size

正是这个 Validator 驱动了整条降级链:Tier 1 切出来碎块太多?拒绝,降到 Tier 2;Tier 2 还是不行?降到 Tier 3。它把「切得好不好」变成了一个可自动判断、可自动重试的闭环,而不是靠人肉调参。

父子分块:小窗口检索,大窗口回答​

前面解决的是「怎么切得合理」,父子分块解决的是「粒度两难」。开启 enable_parent_child 后(chunker.SplitParentChild),分片变成两级:

  1. 先用 parentCfg(默认 4096 字符)切出父块;
  2. 每个父块再用 childCfg(默认 384 字符、overlap 约为子块的 1/5)切出子块;
  3. 子块的 Start/End 平移回文档级偏移,ParentIndex 指向父块。

服务侧落库规则(knowledge_process.go 的 processChunks)是关键:

  • 父块 → ChunkTypeParentText,只进数据库、不进向量索引,父块之间用 PreChunkID/NextChunkID 串成链表;
  • 子块 → ChunkTypeText + ParentChunkID,是唯一被嵌入和索引的粒度;
  • 检索时命中子块,返回的却是父块内容——小窗口保证精确匹配,大窗口保证上下文完整。
flowchart LR
T["全文"] -->|"parentCfg 4096"| P1["父块 P0<br/>parent_text"]
T --> P2["父块 P1"]
P1 -->|"childCfg 384"| C1["子块 C0<br/>text, parent=P0"]
P1 --> C2["子块 C1"]
P2 --> C3["子块 C2"]
C1 -->|"EmbeddingContent<br/>=面包屑+内容"| V["向量/BM25 索引"]
C2 --> V
C3 --> V
P1 -.->|"不进索引,命中子块后回捞"| R["检索返回父块内容"]
V --> R

源码里特别强调 buildParentChildConfigs 必须把 Strategy 透传给父子两级配置,否则空 Strategy 会被解析成 legacy tier,父子块会静默丢失标题对齐和 ContextHeader 面包屑——这是一个很容易踩的坑。

ContextHeader:只给向量看的语境​

Chunk.ContextHeader 是一个和 Content 分离存储的字段,装的是标题面包屑:

func (c *Chunk) EmbeddingContent() string {
body := strings.TrimSpace(c.Content)
if c.ContextHeader == "" { return body }
return c.ContextHeader + "\n\n" + body
}

设计要点有两个:

  • 只影响 embedding,不影响原文。索引时组装的内容是「知识标题 + ContextHeader + Content」,让向量携带章节语境;而 Content 保持逐字原文,Start/End 偏移不变式依然成立。
  • 持久化到数据库(chunks.context_header 列)。早期它是内存字段、索引完就丢;引入 chunk 手工编辑后,重新索引单个块时必须复现同样的索引输入,所以改成落库。接口响应里仍不返回它。

一句话:ContextHeader 让「1.2 节里的一句话」在向量空间里知道自己属于「第一章 · 1.2 节」,检索时更容易被正确召回。

表格与图片的特殊处理​

大表格被切成多块后,后续块会丢失列名。header_tracker.go(移植自 Python header_hook.py)解决这个:检测「表头行 + 分隔行」作为活动表头,落新块时如果表头没在重叠区出现且列数匹配,就把表头作为零宽单元前置到新块——每个表格分片都自带列名。空表头还会用第一行数据补全列名。

图片方面,![alt](url) 是受保护模式永不被切断;ExtractImageRefs 提取块内图片引用建立关联;每张图片在多模态阶段还会生成 image_caption / image_ocr 两个子 Chunk 单独索引,让图片语义也能被召回、命中后回到原文块。

参数速查​

最后放一张调参速查表,来自官方文档,实际用起来很有参考价值:

场景strategychunk_sizeoverlap备注
通用文档(起点)auto51280—
结构化技术文档auto(命中 heading)512–102480面包屑自动生效
OCR PDF / 纯文本书auto(命中 heuristic)512–102480–150指定 languages 减少误判
长叙事 / 论文auto1000–2000150–200可叠加父子分块
精确检索 + 长上下文任意——enable_parent_child=true
FAQ / 原子记录不适用—0逐条成块,不重叠
严格 token 上限任意——设 token_limit 自动换算字符预算

WeKnora 还提供了一个只读预览端点 POST /api/v1/chunker/preview,改参数前可以先试切样例文本、看诊断信息(选中的 Tier、被拒的 Tier 及原因、完整画像、每块的统计),不写库、不产生 embedding。这个「先预览再入库」的调试能力,对调分片参数帮助极大。

小结​

WeKnora 的分片器给我最大的启发是:它没有追求「一个更强的分片算法」,而是搭了一套会自我诊断和自我纠错的框架——画像器负责看清文档、三个 Tier 各司其职、Validator 负责质检、降级链负责兜底,再叠加父子分块和 ContextHeader 解决粒度和语境。分片从一个「拍脑袋定参数」的活,变成了一个有反馈闭环的工程系统。

下一篇进入检索环节,看它命中这些块之后,又是怎么用一条 12 阶段的管线把最相关的内容捞出来、排好序、喂给模型的。

系列导航:

  • (一)架构全景:Go 与 Python 双引擎
  • (二)自适应分片机制:三层策略与父子分块 ← 本文
  • (三)RAG 检索管线:12 阶段 Pipeline 剖析
  • (四)ReAct Agent 与三大能力
  • (五·番外)向量库可插拔抽象