ARTICLE · 1225272
文档摄入与切片:召回率的地基,切错了后面全白搭
文档摄入与切片:召回率的地基,切错了后面全白搭
很多人以为 RAG 的难点在"检索"和"生成",其实地基在摄入(Ingest)。我们做过对照:同样的向量模型和检索管道,只是把切片策略从"整篇丢进去"换成"滑动窗口 + 父子分片",召回率就有肉眼可见的差距。这一期把摄入与切片讲透。
一、为什么不能整篇向量化
向量是按"块"算语义的。一篇 8000 字的规范整段向量化后,一个查询向量去匹配这个超长向量,余弦相似度会被"平均"掉——真正相关的那 200 字,淹没在另外 7800 字的噪声里。所以切片是必须的,问题是怎么切。
二、四种切片策略,从弱到强
- 固定窗口(naive)
:每 500 字一刀。简单,但句子被腰斩,语义割裂。 - 滑动窗口(overlap)
:窗口 500 字、步长 400 字(overlap 15–20%)。相邻块重叠,缓解边界割裂。这是我们 Phase 1 的基线。 - 语义/结构切片
:按标题、段落、表格边界切,保留结构。质量最高但实现复杂。 - 父子分片(small-to-big)
:小片(300–500 字)用于向量化与召回,命中后回链父大片(含上下文)喂给生成。这是性价比最高的方案——召回精准、生成完整,两全其美。
我们的落地是:摄入时同时落"小片向量"和"父片原文",召回命中小的,返回大的。这样既不丢失长文档的语义整体,又让短查询能精准命中局部。
三、切片之后的状态机:杜绝僵尸向量
这是踩出来的坑。早期摄入是"写完就标成功",结果一批向量其实是空的(见第 3 期空向量事故),却被标记 embedding_status=1,永远进了召回池,拉低整体质量。
正确做法是用状态机:
pending → vectorizing → done(1) // 仅 done 进入召回
↘ failed(2) // 空向量/超时,告警 + 可重试
只有 done 的分片参与召回;failed 的一律标记并告警,绝不假装成功。状态机还解决了"重跑幂等"问题:失败的分片可以定向重试,而不是整库重来。
四、摄入侧还要做两件事
- 批量嵌入
:用 BGE-M3 的批量端点(batch-size=32),吞吐远高于逐条;摄入几万片文档时这是硬指标,逐条嵌入会慢一个数量级。 - 回读校验
:写入 Qdrant 后对首批向量回读,断言"非空 + 维度 == 配置"。空向量在摄入侧就拦下来,比在查询侧救火便宜得多。
五、代码片段(父子分片)
// 父子分片:小片用于向量化/召回,父片用于生成回填
List<Chunk> smalls = slidingWindow(doc, 500, 100); // 500字窗, 100字重叠
for (Chunk s : smalls) {
intparentId= linkToParent(doc, s); // 回链父大片
qdrant.upsert(s.toPoint(parentId)); // 存小片向量 + parent_id
}
六、与检索的衔接
切片策略还会影响后续:父子分片的"小片"参与向量召回,"父片"在 Layer5 来源装配时回填给生成;滑动窗口的 overlap 让边界查询不被割裂。这些在检索层设计时要预先约定 payload 的 chunk_id / parent_id 关系。
小结
摄入阶段决定"能召回什么":滑动窗口保证不漏边界,父子分片兼顾精准与完整,状态机杜绝僵尸向量,回读校验拦住空向量。这四件事做对,后面的检索和生成才有意义。
下期讲向量化本身——EmbeddingPort 怎么设计成可插拔,以及我们那次"召回率为 0"的 P0 事故根因。















































































