同一份 60 页 PDF,今天解析出 41 个段落,昨天 63 个;问同一个问题,两次引用的页码还不一样。这不是模型不稳定,是输入管线没有被当成有版本的工程:它由三段一次性脚本拼成,谁都能改一行正则,改完没人重跑历史数据,也没人知道结论为什么变了。
模型侧参数大多可观测,输入侧几乎不可观测。这篇只谈四件事:管到哪、清洗到哪该停、去重按什么粒度、对齐为何是溯源前提。
1. 四个环节与各自的坏法
| 环节 | 输入 → 输出 | 典型失败 | 外在表现 |
|---|---|---|---|
| 采集 | 源 → 原始文件 | 抓到登录页、旧版本、漏页 | 内容缺失但文件数正常 |
| 清洗 | 原始 → 纯文本 | 噪声残留、过度删除、编码错 | 命中乱码,或关键句消失 |
| 去重 | 文本 → 唯一集合 | 近重复漏判、异文被合并 | 同一事实反复引用,或内容串台 |
| 对齐 | 文本 → 带坐标的块 | 坐标错位、页眉混入 | 答案对但引用页码错 |
最小 DAG(每个节点都要回答:多少条进、多少条出、失败几条)
源 → [采集] → raw/ ─hash─┐
├→ [清洗] → text/ ─统计─┐
[版本登记] ─────┘ ├→ [去重] → uniq/
│
[对齐] ← 原始版面坐标 ┘
↓
chunks/ + manifest.json
失败必须显式发生。把失败降级成"产出一段空文本继续走",后面所有指标都会失真且不报警。
2. 清洗的三类与"清洗过度"
清洗是有损压缩,删掉的每一处都可能是答案所在的句子。
| 类型 | 对象 | 处理原则 | 过度的后果 |
|---|---|---|---|
| 格式噪声 | 页眉页脚、页码、水印、断行连字符 | 按位置与重复模式删 | 删掉正文里的表格标题 |
| 编码问题 | 乱码、全半角混用、异常空白 | 先检测再转换,不认识的不动 | 整段被替换成空 |
| 结构混杂 | 目录、参考文献、脚注、图注 | 分类保留并打标签,不混正文 | 图表说明污染语义 |
# 规则的最小可审计写法:每条都有 id、作用域、预期命中量
rules:
- id: strip-running-header
scope: page_top_lines
match: "第 \\d+ 页|^\\s*-\\s*\\d+\\s*-$"
expect_hits: "每页 1—2 行"
- id: join-hyphen-break
scope: paragraph
match: "(\\w)-\\n(\\w)"
replace: "$1$2"
- id: keep-footnote-tag
scope: inline
action: tag_only # 不删,只打标
判断过度的两个证据:清洗前后字符数比值、抽样段落还能否回答原问题。干净但没信息的文本是负资产。
3. 去重的三个粒度
难点不是找出完全相同的两份,而是找出实质相同、措辞不同的两份,并判断该不该合并。
| 粒度 | 判据 | 成本 | 误判方向 |
|---|---|---|---|
| 字符级 | 归一化后 hash 相等 | 极低 | 漏判(改一个词就漏) |
| 段落级 | 段落 hash / 最小哈希签名相似度 | 低 | 首尾差异导致漏判 |
| 语义级 | 向量相似度阈值 | 高(需嵌入) | 把观点相反的两段合并 |
# 段落级近重复:先归一化再取签名,阈值须按语料类型核对
python3 - <<'PY'
import re, hashlib
def norm(t):
t = re.sub(r'\s+', '', t).replace(',', ',').replace('。', '.')
return re.sub(r'[^\w\u4e00-\u9fff]', '', t) # 中文不必小写
def sig(t, k=5):
n = norm(t)
return {hashlib.md5(n[i:i+k].encode()).hexdigest() for i in range(len(n)-k+1)}
PY
合并要写清保留哪份:通常是元数据更完整的那份(含页码、版本、来源),而非更长的那份——丢掉的元数据正是溯源断掉的地方。
4. 对齐:坐标映射是溯源的前提
对齐最容易被跳过,也最贵。它要回答:这段文字出自原始文件的哪一页、哪一节。没有它,检索命中后无法回原文核对,页码只能靠模型编。
| 坐标类型 | 来源 | 精度 | 失效场景 |
|---|---|---|---|
| 页码 | 解析器分页标记 | 页级 | 双栏重排后页内顺序错 |
| 字符偏移 | 清洗后正文的位置 | 段落级 | 清洗删改后偏移漂移 |
| 结构路径 | 标题层级(H1/H2/H3) | 节级 | 标题识别失败,路径断裂 |
| 版面框 | 原始 PDF bbox | 行级 | 重排、缩放后失效 |
{
"chunk_id": "doc-118-p07-c03",
"text": "绿地滞蓄雨水的容量与土壤渗透率相关……",
"anchor": {"page": 7, "char_span": [10432, 10711],
"heading_path": ["第三章 城市水环境", "3.2 绿地系统"]},
"pipeline": {"clean": "r2024-11-3", "chunk": "c1.4", "parser": "p2.1"},
"source": {"path": "raw/kb/城市水环境.pdf", "sha256": "…", "version": "2024-09"}
}
三条实践:偏移算在清洗后文本上并保留原始映射;结构路径比页码稳;bbox 只作人工核对。
5. 结构保留与多源冲突
| 元素 | 是否保结构 | 理由 | 落地 |
|---|---|---|---|
| 标题层级 | 必须 | 决定块边界与路径 | 解析为树,不摊平 |
| 表格 | 必须 | 行列关系即语义 | 独立抽取,块类型标 table |
| 代码块 | 必须 | 缩进与换行是语义 | 禁走正文清洗规则 |
| 页码 | 必须 | 溯源与引用 | 进 anchor,不进正文 |
最容易做错的是表格:摊平成一行看似信息都在,行列关系已丢,模型读到的是一串语义不明的数字。
多源冲突必须给裁决规则,否则检索会在两版间随机命中:
| 冲突类型 | 判据 | 裁决 |
|---|---|---|
| 版本升级 | 版本号/日期/hash 不同 | 新版生效,旧版标 superseded(不删) |
| 同版异源 | hash 不同但版本号相同 | 两份都留,标来源 |
| 无法判定 | 无版本、无日期 | 全留,冲突在检索层显式提示 |
禁止静默覆盖旧版本:删掉的那版常是解释"为什么改"的唯一证据。
6. 可重复性与版本绑定
同输入必同输出是管线能被信任的最低条件,要求同时固定解析器、清洗规则、分块参数。
| 变量 | 漂移来源 | 绑定方式 |
|---|---|---|
| 解析器 | 依赖升级、字体缺失 | 锁版本 + 记录 binary hash |
| 清洗规则 | 有人改了一行正则 | 规则集版本号写入产物 |
| 分块参数 | 换 size / overlap | 参数进 manifest |
| 去重阈值 | 调参后忘了回滚 | 阈值进配置,不写进代码 |
manifest.json(每个产物目录一份,随产物提交)
{
"pipeline_version": "2024-11-3",
"parser": {"name": "pdf-parse", "version": "x.y", "binary_sha256": "…"},
"clean_rules": "clean-r2024-11-3",
"dedup": {"granularity": "paragraph", "threshold": 0.86},
"chunk": {"size": 512, "overlap": 64, "by": "heading"},
"inputs": [{"path": "…", "sha256": "…"}],
"counts": {"raw": 128, "parsed": 126, "failed": 2, "chunks": 1902}
}
有了它,"两次结论不同"变成一次 diff:比版本号、比计数、比规则 id。
7. 质量评估与成本结构
| 方法 | 覆盖 | 能发现 | 成本 |
|---|---|---|---|
| 分层抽样人工核对 | 数十条 | 语义错误、结构丢失 | 中,需人 |
| 字符数/页数比 | 全量 | 扫描件空文本、解析失败 | 极低 |
| 引用回链抽样 | 中 | 对齐错位(页码/偏移) | 低 |
自动检查抓不到语义错误,人工核对覆盖不了全量。资源有限时先做全自动检查,人工抽样只用于估计错误率量级。
| 成本项 | 驱动 | 优化方向 | 不能省 |
|---|---|---|---|
| 解析耗时 | 页数、版面复杂度、OCR | 只对失败页走 OCR | 结构抽取 |
| 存储 | 原文 + 文本 + 块 + 向量 | 原文只存一份,派生可重建 | 原文与 manifest |
| 重跑 | 规则变更触发全量 | 按 hash 增量 | 影响范围内全量 |
| 人工核对 | 条数 × 单条耗时 | 分层抽样,先抽高风险类 | 一轮基线核对 |
多数组在存储上省、在重跑上花:省一份原文备份,规则一改就得回源头重抓。
8. 变更回归与下游契约
改清洗规则不是局部修改,它让历史结论过期。每次变更回答三问:影响哪些文档、哪些结论要重评、旧结论是否标失效。
| 变更 | 影响范围 | 回归动作 |
|---|---|---|
| 加/删噪声规则 | 全量块文本 | 全量重跑 + 重算向量 |
| 加结构规则(表格) | 含表格文档 | 按文档筛选重跑 |
| 调分块参数 | 全量块边界 | 全量重跑并重建索引 |
| 调去重阈值 | 块集合 | 重跑去重,检查块数变化 |
硬规则:管线版本与结论版本绑定。对外给出的答案要能追到当时的 manifest,追不到就不能当结论引用。
对下游有两个契约,破坏任一个都会让上层莫名其妙地坏:
| 契约 | 内容 | 破坏后的症状 |
|---|---|---|
| 块元数据 schema | 必含 chunk_id、anchor、doc_id、块类型 | 能命中但无法溯源、无法按类型过滤 |
| 块边界语义 | 块是自足语义单元,不切断句子与表头 | 答案缺半句,模型自行补全 |
| 文本归一约定 | 空白、全半角、换行约定一致 | 关键词漏命中,评测分数漂移 |
9. 排错:四种"同一份文档两种结论"
| 现象 | 原因 | 处理 |
|---|---|---|
| 两次解析块数不同 | 解析器版本或字体环境不同 | 锁版本,记录 binary hash |
| 块数正常但内容缺失 | 清洗正则过宽误删 | diff 清洗前后,逐条规则统计命中 |
| 命中重复段落 | 去重粒度太细 | 改段落级 + 签名相似度 |
| 答案对、页码错 | 偏移算在原始文本,清洗后未重算 | 双份偏移 + 映射表 |
10. 边界:什么时候不必建管线
| 场景 | 是否适用 | 理由 |
|---|---|---|
| 输入单一且干净(自家 JSON / 规范 Markdown) | 不必 | 清洗与对齐收益接近零 |
| 一次性分析任务 | 不必 | 版本化与回归的价值来自重复运行 |
| 多源、长期维护、结论对外引用 | 必须 | 三条同时成立时,管线就是产品的一部分 |
11. 自测
- 为什么"失败被降级成空文本继续往下走"比直接报错更危险?
- 判断清洗过度,哪两个证据最直接?为什么"更干净"不等于"更好"?
- 去重的三个粒度各往哪个方向误判?合并时该保留哪一份?
- 对齐缺位时,模型给出的页码为什么必然不可信?
- 改了清洗规则后,哪些结论必须重评?依据什么判断影响范围?
- 什么条件下可以不建管线,用人工核对替代?