知识库上线三个月,检索层没坏,回答全过期了。上线那天灌了 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. 自测
- "检索没坏但回答过期",为什么换嵌入模型解决不了?
- 增量判定为什么以指纹为主、时间戳只做剪枝?指纹算在哪一层?
degraded与failed的区别是什么?哪种允许进索引?- 重叠超过 20% 时,该优先怀疑哪个设计决策?
- 块级替换为什么不能让块 id 依赖位置序号?
- 双索引切换里,别名解决的到底是什么问题?