3071 字
约 10 分钟
14
S20-A06 · RAG 数据管道:采集、切分与增量更新

知识库上线三个月,检索层没坏,回答全过期了。上线那天灌了 1200 篇文档,之后再没有过一次全量重建;源头改了 300 多篇、删了 40 篇,索引一篇都不知道。监控一切正常:命中率没跌——它稳定地命中三个月前的正确。

归因通常指向检索层:换模型、调 topK、加 rerank。但检索层只对索引里已有的东西负责。问题在管道:它被写成一个一次性导入脚本,从来没被当成长期运行的系统。


1. 五个阶段,五种坏法

阶段 输入 → 输出 典型失败 外在表现
采集 源 → 原始文件 权限失效、增量漏判 内容永久停在某一天
解析 文件 → 纯文本 版式错乱、扫描件出空文本 命中乱码段落,答不出
切分 文本 → 块 语义边界被切断 答案缺半句,引用不可信
嵌入 块 → 向量 模型版本漂移、超长截断 新旧向量不可比,相似度失真
入库 向量 → 索引 主键冲突、部分写入 条数对不上,静默丢块

判断管道是否合格只看一点:每个阶段能否单独回答"多少条、多少条失败、最旧一条多旧"。

2. 采集:三种来源的增量信号

"看修改时间"在三种来源上都不够用。

来源 增量信号 可靠性 漏判场景
文档库 mtime + 大小 中 批量拷贝保留旧 mtime;工具重置时间
数据库 自增 id / updated_at / binlog binlog 高,时间字段低 直接改库不改时间;软删行没被发现
网页 / API ETag、Last-Modified、内容 hash 低到中 服务端不给 ETag;改版但 URL 不变

可靠做法是指纹为主、时间戳只做剪枝:先用时间圈候选集,再对候选集算归一化 hash。只用时间会漏,只用 hash 会每次全量读源。

# 先按时间圈范围,再算指纹,最后与上次快照比对
find /data/kb -newermt "$(date -d '2 days ago' '+%F %T')" -type f -name '*.md' \
  | xargs -r sha256sum > candidates.sha256
comm -3 <(sort prev.sha256) <(sort candidates.sha256)   # 输出变更/新增

指纹要算在解析后的正文上,不是文件字节。PDF 重导出、页眉时间戳、样式微调都改字节而正文未变,按字节算等于每次全量重切重嵌。

3. 解析:让失败显式发生

解析最严重的错误不是"不准",是失败被当成"解析出一段短文本"继续往下走。扫描件没有文本层,抽取器返回空串,空文本照样切块入库,长期以低分噪声稀释召回,全程不报错。

形态 常见错 判据 处置
双栏 PDF 阅读顺序错乱,两栏交错 异常断行、句号密度异常 按坐标重排,失败整篇隔离
表格 单元格串成一行、表头丢失 分隔符数量与列数不符 单独走表格抽取,别混进散文
扫描件 空文本、乱码 字符数/页数比低于阈值 走 OCR,置信度低标 needs_review
代码块 缩进压平、混入行号 出现 Page 3 of 12 整块切,不参与句子切分
加密/损坏 抛异常或零字节 密码保护、文件头坏 显式 failed,进退信

实现上用三态结果对象 ok / degraded / failed,降级必须写原因;只有 ok 与人工确认过的 degraded 允许进索引。

{
  "doc_id": "kb-2026-0417",
  "parse_status": "degraded",
  "degrade_reason": "table_extraction_partial",
  "parser": "pdfbox-3.x", "page_count": 42,
  "chars_per_page": 438, "content_sha256": "9f2c...e41a"
}

chars_per_page 是性价比最高的判据:正常文档典型量级为每页数百到一两千字符,扫描件常常只有几十。

4. 切分:策略跟着内容类型走

一个知识库至少有五类内容,一套参数全切一遍是常见的质量损失。

内容类型 切分单位 典型块长 重叠 理由
制度长文 标题层级 400–800 字 10%–15% 章节即语义单元
FAQ 一问一答 整对不切 0 拆开失去问答关系
表格 整表或按行分组 按行数 0 表头跟着数据走
代码/配置 函数、配置段 按结构 0 句子切分破坏语法
会话记录 时间窗 300–600 字 20% 上下文依赖强

重叠解决"答案跨在块边界",代价三重:条数与嵌入费按比例上涨(15% 重叠多付 15%);同段内容多块重复命中,挤掉不同结果;一个改动要重写相邻两块。超过 20% 通常说明切分单位选错——真需要大上下文时该做父子块(小块检索、大块喂模型)。

# chunking.yaml —— 按类型分派,别用一组全局参数
defaults: { unit: heading, max_chars: 800, overlap_ratio: 0.12, min_chars: 120 }  # 碎片并入
overrides:
  faq:     { unit: qa_pair, max_chars: 600, overlap_ratio: 0.0 }
  table:   { unit: row_group, rows_per_chunk: 20, keep_header: true }
  code:    { unit: code_block, max_chars: 1200, overlap_ratio: 0.0 }

5. 元数据:五个必带字段

字段 取值 缺失后果
来源 source_uri + source_type 无法回溯原文,质疑时无据可查
版本 文档版本或 hash 前 12 位 不知索引里是哪一版,回滚无从下手
时间 source_updated_at + indexed_at 算不出陈旧度,过期内容发现不了
权限 acl_scope(部门/密级/可见组) 只能全库检索,等于摊给所有人
定位 页码、章节路径、字符偏移 只能引到"某篇",不能引到具体位置

权限要在检索时过滤,不是检索后过滤:后过滤会让无权块参与打分、污染 topK,还在日志留下不该出现的文本。做法是把部门与密级拼成 FilterExpression,随 SearchRequest 一起下发(Spring AI 的 filterExpression),而不是查回来再 removeIf。

6. 增量更新:三种粒度

粒度 适用 单次成本 一致性风险
整篇重建 大改版、解析器或嵌入模型升级 高 低,天然原子
块级替换 局部修订 低 中:块 id 含位置序号会整体错位
软删标记 源撤下、临时下线 极低 中:检索漏写过滤等于没删

块级替换的前提是块 id 不依赖位置。doc_id + 序号 在中间插入后全体位移,"改第 3 段"变成删 3 插 3。改用 doc_id + 稳定子键(标题路径 + hash 前缀)避免位移,代价是改名被当成新增。

BEGIN;
DELETE FROM kb_chunk WHERE doc_id = :doc AND chunk_key = :key AND version < :newVersion;
INSERT INTO kb_chunk (doc_id, chunk_key, version, text, acl_scope, anchor, indexed_at)
VALUES (:doc, :key, :newVersion, :text, :acl, :anchor, NULL);
COMMIT;

写库与写索引不是一个事务。兜底原则是以库为准、索引可重建:先落库留 indexed_at = NULL,补偿任务扫描补写。

7. 删除:真删与墓碑

方式 行为 优点 后果
真删 物理移除块 干净、省空间 无审计、误删不可恢复、部分向量库删除有延迟
墓碑 标 deleted_at,检索过滤 可恢复、可解释 体积持续增长,过滤漏写即泄漏

"删不干净"会留三处痕:索引仍能召回、缓存仍返回旧答案、评测集仍以旧答案为准。第三种最隐蔽——一次正确删除在回归里表现为"效果下降"。墓碑要按合规保留 30–180 天,到期由离线任务物理清理。

8. 双索引切换与回滚

换模型或改切分不能原地覆盖:新旧向量不可比,混在一个索引里相似度没有意义。

curl -s -X POST "$ES/_aliases" -H 'Content-Type: application/json' -d '{
  "actions": [
    { "remove": { "index": "kb_v3", "alias": "kb_active" } },
    { "add":    { "index": "kb_v4", "alias": "kb_active" } }
  ]}'
要求 做法 不做的后果
切换原子 代码只认别名 窗口内一半打旧一半打新
可回滚 旧索引留一个评测周期 出事只能重建
可比 新旧跑同一评测集 变差后分不清是模型还是切分

9. 可观测与重建调度

指标 定义 异常信号
阶段条数 五阶段记录数与通过数 相邻阶段比例突变(解析通过率骤降)
失败率 按失败原因分组的 failed 占比 单一原因过半,通常是某源格式变了
阶段延迟 采集到可检索的耗时分布 尾部拉长,重试或限流在打转
陈旧度 now - source_updated_at 分布 中位数线性上升,说明增量没跑或漏判

陈旧度不需要埋点,存了时间字段就能算,健康库的中位数应稳定在采集周期量级。

调度 触发 新鲜度 成本特征
定时全量 cron 差 稳定可预测,适合小库
定时增量 cron + 指纹 中 低,有窗口期
变更触发 webhook / binlog 好 峰值不可控,需合并削峰

重建成本几乎全在嵌入调用,按 块数 × 平均 token × 单价 估算。节流关键不是限速而是合并:10 分钟内同一文档的变更合成一次。

10. 排错

现象 原因 处理
回答出现已删内容 未过滤 deleted_at,或缓存未失效 过滤下推;删除时失效缓存键
索引条数少于文档数 解析失败被跳过、超长块被截断 按 parse_status 分组统计,块长对齐模型窗口
每次都全量重建 指纹算在文件字节上 改算正文归一化 hash
局部修订后段落重复 块 id 含位置序号 改稳定子键(标题路径 + hash 前缀)
权限外内容被召回 结果侧过滤、元数据缺失 过滤进查询;缺元数据的块拒入

11. 边界:什么时候不必建管道

场景 判据 该怎么做
知识量小 一次灌完且几分钟跑完 脚本 + 定时全量
几乎不变 季度级变更 手动重建 + 变更登记
结构化程度极高 本身是表格字段且有主键 走精确查询,向量只做补充

出现任一条,管道就是必需品:来源 ≥ 3 类、有权限分级、需引用定位、有审计要求、答错造成实际损失。


12. 自测

  1. "检索没坏但回答过期",为什么换嵌入模型解决不了?
  2. 增量判定为什么以指纹为主、时间戳只做剪枝?指纹算在哪一层?
  3. degraded 与 failed 的区别是什么?哪种允许进索引?
  4. 重叠超过 20% 时,该优先怀疑哪个设计决策?
  5. 块级替换为什么不能让块 id 依赖位置序号?
  6. 双索引切换里,别名解决的到底是什么问题?
S20-A06 · RAG 数据管道:采集、切分与增量更新
https://www.clxhxhhr.top/posts/6132/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。