作者复盘在4千章节网文上触发真实边界后,意识到chunking本质是数据建模决策而非prompt修补,提供了分层设计思路。
系列:AI Engineering · 上一篇:#01 — 当 AI 丢弃自己的搜索结果时
这是一篇关于我自己系统的验尸报告,不是别人的。先说一个让人不舒服的事实:我知道书籍可能非常庞大——这在项目目标里白纸黑字写明了,在任何一行代码写之前就已经写好了。我只是没有把这条知识当作设计约束来对待。我把它归到"以后再处理",按照常见情况来构建,让每一层都默默地把"整本书都能装下"写死在里面。然后我把它对准了一部真正的 4000+ 章节的网络小说,所有功能同时崩溃了。这个教训——"知道不等于设计"——花了我超过一个月的重构工作,而且我今天还在为此付出代价。我把它写下来,这样你只需花阅读的时间就能买到这个教训。
我把分块——把大输入拆分成更小的单元——当作是在 LLM 调用时做的事,就在接近上下文窗口之前:拿到文档,意识到装不下,切成块,循环。问题解决了。我不认为只有我一个人从这里起步——但我要用自己的系统来论证,不是我没有的统计数据。
问题并没有解决。它只是被推迟了,而且利息还在累积。
当你已经在提示词边界上切分文本时,系统的其余部分已经commit到了相反的假设。文档在数据库里是一行,在队列里是一个任务,在 API 里是一个请求,在缓存里是一个条目,在 UI 列表里是一个项目。在 LLM 调用时分块只修好了提示词,却让其他每一层都继续相信整本书仍然能装下。所以整件事就崩溃了——不是在模型层,而是在数据库、任务运行器、API、前端——第一次输入真正大的时候。
解决方法是把你工作单元的粒度当作一个基础性决策来对待——并且保持这个决策可逆,这样就没有一层会把"整个文档"写死在里面,导致以后不得不迁移。我要论证两个理念,故意做成一对。这两个标签是我自己起的——不是行业既定术语——是我在连接更老的工程理念,不是新的原语:
Chunk-first(分块优先) — 时间层面的教训。在行数、客户端和缓存积累到让变更变得昂贵之前,尽早决定你的工作单元粒度,并在 schema 中给它一个身份。
Chunk-native(原生分块) — 结构层面的教训。当规模到来时,每一层做工作的东西——存储、任务、缓存、API——都在 chunk 上操作,而不是 document。就像 cloud-native:属性渗透整个栈,而不是只存在于一个函数里。
DOCUMENT-NATIVE (默认——在规模下会崩溃)
┌───────────────────────────────────┐
│ WHOLE DOCUMENT │ ─── 一行 · 一个任务 · 一个请求 ·
└───────────────────────────────────┘ 一个缓存键 · 一个提示词
只有提示词会被拆分——陷阱所在
CHUNK-NATIVE (chunk 承担工作;document 组织它)
document = container (排序 + 清单 + 所有权边界)
┌──────┬──────┬──────┬──────┬──────┐
│ chunk│ chunk│ chunk│ chunk│ ... │ ─── CHUNK 是工作单元:
└──────┴──────┴──────┴──────┴──────┘ 一行 · 一个任务 · 一个请求 ·
每个 chunk 有:id · hash · version 一个缓存键 · 每个 chunk 一个提示词
[!IMPORTANT] 这是整篇文章的一句话总结。Chunk-first 不是指"一开始就构建原生分块栈",也不是指"在第一天就选择完美的粒度"。它指的是做那个廉价的决策,让接缝保持开放:在 schema 中给你的工作单元一个身份。把这件正确的事做对了,以后走向 chunk-native 的迁移成本是你能控制的。如果跳过它,那就是一次你不得不一次性支付的迁移,而且时间由你最大输入那个来定。
[!NOTE] 一个区分说明,因为这个词被重载了。如果你做 RAG,"chunking"几乎肯定指的是检索分块——拆分文本让嵌入检索效果更好(固定大小、递归、语义、后期分块)。这不是本文要讲的。检索分块是一个分块边界,在一个层。我指的是更老的、数据工程意义上的分块:你的整个系统在其上操作的单元的粒度——数据库行、队列任务、API 条目、缓存键。检索分块是一个特例。当我说分块优先时,我指的是工作单元的决策,不是嵌入窗口的决策。
一个 document 可以同时携带多个粒度,它们不必一致:
DOCUMENT(一本书)
├── processing grain → a scene (一个提取任务,一个缓存键)
├── persistence grain → a scene (一行,一个 id)
├── job grain → a scene (一个检查点)
├── meaning grain → a chapter (用户命名和拥有的东西)
└── retrieval grain → ~400 tokens (嵌入效果好的大小)
RAG 对话只讨论最后一行。本文讲的是其他四行。
在你开始之前一个诚实说明:这是一个领域中的一个系统。把这些主张当作一个经过长期的、可变的、用户提供的文本塑造的强假设,而不是已证实的定律。
目录——以及根据你的角色从哪里开始
从哪里开始。如果你已经用 declare-the-grain、durable-execution 和 semantic-operator 的术语来思考,你已经知道这些机制——略过 None of this is new(唯一为你写的那一节),然后拿 retrofit playbook。如果你是只做过检索分块的 RAG 工程师,全部读一遍:核心主张是"分块"命名了两个不同的决策,而你只做了一个。职业生涯早期?跳到 How to tell you built document-native 并把它当作检查清单使用。
四个失败模式(以及一个决策)
原则:chunk-first & chunk-native 数一下假设一个 document = 一个工作单元的层 工作单元是一个一等对象 Chunk-native 检查清单 一个最小的 chunk-native 草图 选择粒度——四边界决策过程
数一下假设一个 document = 一个工作单元的层
工作单元是一个一等对象
Chunk-native 检查清单
一个最小的 chunk-native 草图
选择粒度——四边界决策过程
适用场景,以及如何改造 当你不需要它时 一致性主导任务:reduce 改变形状 何时是错误的架构 如何判断你构建了 document-native 改造 playbook 实际花费了我多少
当你不需要它时
一致性主导任务:reduce 改变形状
何时是错误的架构
如何判断你构建了 document-native
改造 playbook
实际花费了我多少
附录——lore-weave 案例研究,深入
先验工作与进一步阅读
四个失败模式——以及其下的一个决策
从一般术语的问题开始。每个处理大输入的系统都从一个看起来合理的假设开始:document 是自足的——它能装进一个工作单元(一行、一个任务、一个请求、一个提示词),并且它自带组织,所以不需要存储任何其他东西。这个假设在小数据上不可见,在大数据上是承重的,并且随着输入增长以四种可识别的方式失败。以下是它们作为一般模式的样子;如果你构建过任何摄入用户提供的 document 的系统,你可能至少遇到过其中一个。
四个分别命名的 bug——一个缺失的边界、一个缺失的索引/拆分、一个缺失的检查点、一个被丢弃的事实来源——每一个在隔离状态下都会被普通的称职工程捕获。把它们放在一起命名的原因是,在我的系统中它们共享一个上游假设:document 是自足的。这个假设有两半,它们以不同方式失败:
"它能装下。"一个 document 是一个工作单元——一行、一个任务、一个请求、一个提示词。失败 1-3 是这半边破裂了。它们关于 atom:单元太大了、无界的、或者没有检查点的。
"它描述自己。"document 隐式地携带了重建其自身组织所需的一切,所以产生它的结构不需要存储。失败 4 是这半边破裂了。它关于 container。
这些 bug 每一个也都有其他原因——你可以在一个完全 chunk-native 的系统里写出无界的后台填充,缺失的索引就是缺失的索引,你可以在任何大小丢弃你的 outline。主张不是这个假设是这四个 bug 的唯一来源。而是当你发现其中之一时,这个假设值得检查,因为它往往也产生了其他的。先一个个修复它们是在治疗症状;说出共享的假设会发现其余的,在它们触发之前。
所以一个决策位于这四个问题的底层:你的工作单元是什么——以及它在你的 schema 中是否拥有自己的身份?本文剩余部分就是关于这个决策——如何做出它,以及如果你已经深陷其中如何改造。详细案例研究——一本包含 4000 章的真实书籍,它触发了上述所有陷阱,包含具体引用和数字——放在附录中,供需要证据的读者参考。
如果那张表就是你所读到的全部,你已经抓住了要点。剩余内容是证明和步骤。
将请求从 UI 向下追踪到模型,注意"文档 = 一个单元"这个假设如何静默嵌入每一层——因此失败可能在其中任何一个层暴露:
请求沿此方向流动 ─────────────────────────────────────────>
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ UI │→│ API │→│ JOB │→│ DB │→│ CACHE │→│ LLM │
│ list │ │ request│ │ queue │ │ row │ │ key │ │ call │
└────────┘ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘
"one book" "one call" "one task" "one row" "one key" "fit it all"
^ ^ ^ ^
│ │ │ │
list 返回 job = 1 全量数据集 超大
所有内容 个文档, 扫描,一次 prompt
(无分页) 无法续跑 请求 (第一个症状)
你在最右边(prompt)"添加 chunking",修好最右边的那个框。它左边每一个框仍然把整个文档交给你。所以你只能改造:DB row 变成每个 chunk 一行(schema 迁移 + 回填);job 变成 N 个 job(幂等性、检查点、部分失败处理);API 增加分页(所有客户端必须遵循的契约变更);cache 按 chunk 重新设 key;UI 增加分页器。每一个都是我所付出的代价——而且是在 schema、存储的数据和调用者已经存在之后付出的,这在任何时候变更都是昂贵的。
具体账单:超过一个月的重构工作,而且我仍在为此付出——最细粒度的拆分在其他功能都已上线之后,还在待办事项里躺了很久。这不是重写;我是增量地、原位地进行改造,没有停止功能开发。改造是可以存活的。但那一个月没有买到一个新功能。每个小时都花在了撤销一个本可以在一个下午就选择不做的假设上,而且代价随着你迁移的每一行和需要更新的每一个调用者而累积。这才是真实成本的形态:不是撞一次墙,而是一种税——因为你做设计时没有意识到这个假设,所以一直为之买单。
以下这个重构框架本可以拯救我。在你对领域建模之前,先回答一个问题:
什么是最小的一块输入——一个单一操作必须作用于其上——而且这一块是否值得拥有一个身份?
如果答案是"一个场景",或"一段文字",或"一个 500 token 的跨度",那么它就是你的 row、job、cache key——而不是包含它的那个文档。文档变成一个容器(排序、父级 id、清单、所有权边界),而 chunk 成为工作的原子。
狭义地理解"原子":昂贵操作的原子,而非所有事物的原子。一个文档合法地同时携带多个粒度——用于提取的场景、用于检索的 400 token 跨度、用于用户心智模型的章节——它们不需要一致。我后来得出的规则是:在工作发生的地方用 chunk 粒度;在意义所在的地方用文档结构。本文不是论证一切都变成 chunk。而是论证每个昂贵操作都应该有一个显式的、有边界的处理粒度,而且那个粒度应该在数据模型中可表示。
这并非新东西,而且最古老的名称就是最好的:在维度建模中这叫"声明粒度"——Kimball 的规则,在选择事实表的维度或度量之前先确定其粒度。也就是说,几乎逐字对应的是:"在设计 schema 之前先决定工作单元"——来自 1990 年代。(两个术语说明。我借用 Kimball 的粒度是类比:他的粒度是维度模型中一个事实表行的含义;我的粒度是管道中最小可独立处理的单元。两者的桥梁是真实的——尽早声明,精确声明——但延伸到处理粒度是我的发挥,不是他的。我宽松地使用"工作单元";在 Fowler 的《企业应用架构模式》中,"Unit of Work"是一个特定模式,涉及事务记账,而非记录粒度。把我的"工作单元"理解为"粒度"即可。)
一等 chunk 需要三个文档原生版本永远不会给你的属性:
稳定的身份,在声明的协调策略下。一个 chunk 需要一个 id,供下游引用——图事实、翻译、引用——可以指向它,而不会在作者修复三章外的拼写错误时变成悬空引用。我的方法:在重新处理时,通过(父级,位置)匹配新片段和旧片段,当文本未变时复用 id。这是代理键和第一范式的思考,应用到文本上。
要精确理解这给你带来了什么,因为我没有做到。位置匹配在原地编辑下是稳定的,在插入和删除下会级联失效。在位置 2 插入一个场景,后续每个位置都会偏移:旧场景 B 现在位于 3,因此每个位置上的文本不再与记录在那里的 id 匹配,id 在章节剩余部分就会 churn——这正是该方案本应防止的精确失败。如果你的源内容是结构性编辑而非只是原地更正,位置匹配是不够的,你需要基于内容的对齐(首先用 hash 匹配,回退到位置)或作者分配的锚点。"稳定身份"是你选择和声明的一种策略,不是你免费获得的属性。
同样有帮助的是注意到"身份"是三件不同的事,它们被混为一谈:
它们以不同的速率变化,而把它们混为一谈正是我的缓存和引用彼此不一致不止一次的原因。一个场景在拼写错误修复后保持其逻辑 id;而其内容身份和处理身份都变了。
溯源与新鲜度。每个 chunk 应该知道它来自源的哪个版本,以及现在是否已过期。这是我付出最多工作的地方,而且事实证明也是我重新发明最多的地方:一个可变源必须只重新派生变化的部分,这是增量视图维护 / 变更数据捕获——物化视图、差分数据流、一个深厚且有数十年历史的领域。我用内容哈希重建了一个粗糙的物化视图刷新,因为 RAG-chunking 的写作从不指向那里——不是因为这个问题未解决。如果你的语料库是可变的,在你像我一样手写之前先去读那些文献。
幂等(准确说:是 memoized)处理。处理一个 chunk 应该是一个内容寻址、可回放的函数,这样你就可以设置检查点、续跑、并行化和缓存。两个需要坦诚说明的注意事项,持久执行领域的人会坚持:这是 memoization,不是"应用两次等于一次"的幂等;而且温度大于 0 的 LLM 不是纯函数,所以缓存给出一个稳定答案,而非忠实回放——而且 key 必须包含 prompt 模板和采样参数,而不只是模型版本。
Chunk-native 意味着每一层执行工作的组件都基于 chunk 操作。但并非每一层都应该如此:所有权、排序、计费以及用户的心理模型都保持在文档级别——读者拥有的是"一本书",而不是"第 4,812 场戏"。规则是:工作在哪个层级发生,就在哪个层级做 chunk 粒度的切分;意义存在于哪个层级,就保持文档结构。把 chunk 强行塞进意义层,你就会得到重组的嘈杂——又是 N+1 查询,只不过这次是你自己造成的。
你不需要在第一天就把每一个工作层单元格都构建出来。你只需要确保它们都不会在日后禁止 chunk——具体来说,意味着 chunk 从一开始就是一条拥有自己 id 的记录。这条边界是廉价的。其他一切都可以推迟。
我见过的几乎每一个 chunk-native 流水线都收敛到 split → map → reduce,其中内容寻址缓存保护着中间昂贵的部分:
split (a tested component) map: process each chunk reduce
(memoized + cached) (the hard part)
┌──────────┐ ┌──┬──┬──┬──┐ ┌──────────────────────────┐ ┌───────────────┐
│ document │─>│c1│c2│c3│c4│─> │ key = hash(chunk, op, │─>│ dedup + merge │
└──────────┘ └──┴──┴──┴──┘ │ model, params)│ │ -> glossary, │
parallel, │ hit -> reuse (0 calls) │ │ graph, │
resumable │ miss -> call LLM, cache │ │ summary │
└──────────────────────────┘ └───────────────┘
crash at chunk 3? resume at 3, not at 1.
需要注意三点。split 是一个组件,不是一行代码——它有测试、边界策略和 token 预算;把它当作一等公民,才能让每个功能共享同一个"chunk 从哪里开始"的概念,这也是让你日后在不触碰存储的情况下改变粒度的接缝。reduce 是难点迁移的地方——合并每个 chunk 的结果(去重实体、缝合词汇表、合并图谱)。对于 map 密集型任务,这是个更好的问题。对于那些价值在于全局一致性的任务,一次扁平的 reduce 做不到——这个 fold 必须是递归的。
而且 map 需要一个调控器,否则分解只是把故障转移了位置。这是我最想补救的部分。把 4,232 章分成场景然后全部交给一个会欣然启动它们的运行时,这不是修复——这是把一个超大请求转换成成千上万个同时请求的方式,这就是你如何在同一分钟内遇到提供商的速率限制、连接池上限和重试风暴的。模式不是 chunk 后并行化;而是:
有限分解 + 有限并发 + 检查点 + 记忆化 + 调和
这五项每一项都是承重构件。去掉并发限制,一个成功的 split 就会变成一次自我施加的拒绝服务——比原始故障更糟,因为它现在会把整个系统一起拖下水。限制飞行中的工作,在队列而不是调用点施加背压,让重试变成有预算的而不是自动的。
三个不那么显而易见的技巧,才是真正起作用的,草图里一个都没画:
锚点注入。将少量全局关键实体强制注入每个窗口,这样稀疏但重要的事实才能在 map 阶段存活下来。(这是真正的全局 reduce 的手动、有损替代品——见下文。)
基于位置的 id 复用。用 (parent, position) 匹配重新解析的片段,当哈希值不变时保留旧 id,这样原地编辑不会在下游级联新的 id。(结构性编辑仍然会级联——见上面的身份警告。)
内容哈希清扫器。一种后台扫描,只重新派生哈希值与上次处理的版本相比发生变化的 chunk——一种手写的物化视图刷新。
我没有设计 chunk-first 的原因不是缺乏呼吁——而是在设计时你往往不知道正确的粒度,选错了代价也很高昂(过度 chunking 制造了自己的去重问题——见附录)。这是我希望自己当初有的流程。选出满足所有四个界的最粗粒度——你总能在 split 后面再分得更细,但无法廉价地合并:
操作界(地板)。什么是最小的跨距,一个操作必须一起看到才能正确?场景必须保持完整以进行共指消解;单独的句子不行。设定下限。
适配界(天花板)。什么是最长的跨距,能舒适地放入一个上下文窗口 / 请求超时 / 事务——以你 P99 的真实输入为准,不是你的演示?设定上限。
身份界。什么是最小的跨距,下游产物需要能够指向它并在编辑后仍然存在?如果没有任何东西引用子文档跨距,你可能还不需要 chunk 身份。
变更界。什么是最小的跨距,在源被编辑时独立变化?这就是你的新鲜度逻辑想要的。
如果各界意见不一致,取尊重地板的最粗粒度,并保持 split 的一等地位,这样你以后可以降低边界。你必须在第一天做正确的决定不是粒度——而是存在一个拥有自己身份的子文档单元。粒度是可调的;子表不是。(我选了过粗的粒度,但仍然占了上风,因为边界存在了——完整故事在附录里。)
Chunk-first 是一个廉价的早期决定,但精心设计的机制——检查点、清扫器、有界扇出——在真正需要之前确实是 YAGNI(你不会需要它):
输入是无限的或用户以未知大小提供的。固定的三页 PDF 模板永远不需要它。"用户上传的任何小说"始终需要。
最大现实输入超过一个舒适的工作单元——一个窗口、一个超时、一个事务、一个屏幕。
重新处理是昂贵的(LLM 调用、嵌入),所以缓存值得付出成本。
源随时间变化,你必须只重新派生移动的部分。
如果都不成立,文档就是你的单元,强制使用 chunk 就是过度工程。即使如此,单行边界(给单元一个 id)是廉价的保险——但周围的堆栈不是,提前构建它是过早优化的陷阱。
这里容易说的话是,当产品是全局的时候 chunking 是错误的架构——"总结整本书的主题"、"找出跨越第 1 章和第 4,000 章的情节漏洞"、"这份合同是否自洽"。这是错的,我花了一些时间才看清为什么。
如果输入不适合,分解就不是一种选择。没有一个版本的"总结一本 4,000 章的书"可以跳过分割——你无法把它放进一次调用里,所以唯一的问题是,你用这些碎片做什么。