30 秒判断:RAG 的入库不是“把 HTML 转成文本、每 450 token 切一刀”。检索侧需要文档身份、章节范围、精确原文、稳定顺序、双索引和可回滚快照;只要其中一项丢失,系统仍可能“检索成功”,却给出无法核对或无法解释的答案。本文按真实设计顺序拆开一条 HTML 入库链:原始输入 → 正文提取 → 身份与版本 → 章节树 → 证据块 → dense+BM25 索引 → manifest 对账与原子发布。
核心结论:入库的最终产物不是一串 chunk,而是一份可检索、可引用、可复现的不可变 collection 快照。
适合谁读:已经跑通过“文档上传 → 向量检索”,但开始遇到标题错、章节乱、表格被切坏、引用回不去、文档更新后无法解释的人;准备把 RAG 原型做成长期使用的产品的人。
系列位置:本文属于《文档学习问答 Agent 系列》。需求定义与金标准在前,存储与检索设计随后;本文进入检索侧的下游——HTML 入库,下一篇讲评测台如何固定现场、诊断失败并做回归。
结构速览:§0 为什么先问检索要什么;§1 首版为什么只做 HTML;§2 目标产物;§3 S0–S6 主流程;§4 获取与提取;§5 身份与章节;§6 切块;§7 双索引;§8 快照发布;§9 质量复核;§10 一页速查。
证据标记:📎 官方文档=模型能力与接口参数;🤔 推断=由检索契约和工程约束推得。本文是系统设计稿,尚无整链性能数据,不把“设计完成”写成“质量已验证”。
先交代几个名词
- SourceArtifact:用户选择的本地 HTML,或 URL 返回的原始字节。它是不可变来源记录。
- ExtractedDocument:从 HTML 提取出的格式中立语义节点,例如标题、段落、列表、代码、表格和图片说明。
- SectionTree:由标题层级恢复出的章节树;每个正文节点必须落到唯一
section_path。 - Block:最小可检索、可引用、可枚举的证据单位,不是固定字符窗口。
- collection 快照:某一时刻完整、不可变的一套文档、章节、block、向量、关键词索引和 manifest。
- manifest:快照的账单,记录版本、数量、checksum、构建状态、失败文档和完整性。
- dense/BM25:dense 负责语义召回;BM25 负责专名、数字和关键词召回。两者都是派生索引。
0. 先问检索要什么,再决定入库怎么做
前面已经把存储定为“增强的向量块”:block 正文与元数据为主,dense 和关键词索引为辅。这样一来,入库就不能只交出一段纯文本。
入库应当对检索侧作出五项明确契约:
| 契约 | 入库必须交付的结构 |
|---|---|
| C1 稳定身份与来源 | 每篇文档有稳定 doc_id、标题、来源显示名、别名和文档版本;抓取结果保留原始来源 artifact |
| C2 唯一定位范围 | 每个正文节点属于唯一的 section_path;文档与章节可以按身份稳定筛选,重名标题有确定性消歧 |
| C3 可引用证据块 | block 保存规范原文、字符区间、所属章节、文档内连续序号、内容 hash 和稳定 block_id |
| C4 完整有序覆盖 | block 不跨章节、不重叠、不遗漏;同一文档的 block 覆盖完整正文,并可按 block_index 顺序枚举 |
| C5 索引与快照绑定 | dense 与 BM25 基于同一批 block;两路索引、manifest 和版本清单共同属于一个不可变 collection 快照 |
这些契约与解析器实现无关。HTML 适配器可以随时替换,后续增加 Markdown 或 PDF 也可以复用同一骨架;下游只需要确认新格式经过适配后仍满足 C1–C5。
先定检索契约,再实现解析器。 解析器负责解释输入格式;入库系统负责向检索侧交付身份、范围、证据、顺序、索引和版本。
1. 首版为什么只做 HTML
Agent 学习者会接触很多资料:官方博客、在线文档、研究论文、GitHub README、课程讲义和 benchmark 报告。格式很多,但第一版不能把“所有格式都做一点”当成覆盖面。
格式优先级按“学习价值 × 当前实现收益 × 失败代价”排:
| 优先级 | 格式 | 典型来源 | 首版策略 |
|---|---|---|---|
| P0 | HTML | 官方博客、技术文章、在线文档 | 实现 |
| P1 | Markdown | README、docs、设计说明 | 后续复用同一骨架 |
| P1 | 论文、白皮书、报告 | 后续单独处理版面与页码 | |
| P2 | Word、图片、音视频 | 杂项资料 | 暂不做 |
HTML 也不是无限范围。首版只支持:
- 用户提供的
.html/.htm本地文件; - 公开 HTTP/HTTPS URL;
- 初始 HTML 已含正文的静态页或 SSR 页;
- 标题、段落、列表、引用、代码、表格和图片说明;
- 不执行 JavaScript,不支持懒加载和 JS 空壳页。
这不是认为 JS 页面不重要,而是执行网页脚本会把浏览器内核、隔离、资源占用、超时和 SSRF 一起带进入库链路。首版先用“明确失败”换来可控边界;未来若真实资料大量落在 JS 空壳页,再单独评估浏览器渲染。
2. 目标产物:一份不可变 collection 快照
最终交付物至少包含七类内容:
| 产物 | 关键字段 | 用途 |
|---|---|---|
| 原始来源 artifact | 原始字节、媒体类型、来源键、抓取时间、content hash | 重建、核对与诊断 |
| 文档记录 | doc_id、标题、来源显示名、别名、语言、文档版本 | 文档识别与范围过滤 |
| 章节树 | 唯一 section_path、父子关系、原文顺序 | 按篇/按节检索 |
| block 集合 | 规范正文、字符偏移、章节、顺序、hash、近似 token | 语义检索、全文枚举与引用 |
| 索引包 | 1024d dense、关键词索引、模型/配置版本 | 候选召回 |
| manifest | 快照 ID、状态、完整性、失败文档、checksum | 原子发布与复现 |
| 入库任务记录 | 阶段、进度、错误、重试、最终快照 ID | 暂停、续跑与定位 |
最重要的边界是:
批次可以部分成功,单篇文档不能残缺上线。
一批 10 篇文档里,8 篇完整成功、2 篇失败,可以发布 ready_partial;但任何一篇成功文档都必须完成解析、建树、切块、embedding 和索引。否则 list_blocks 对一个缺章文档承诺“取全”时,完整性已经失真。
3. S0–S6:把输入变成快照
入库不是一个“read → parse → save”的大函数,而是一组有明确输入、输出和不变量的阶段。拆成多步不是为了代码好看,而是为了让错误能定位、任务能续跑、产物能重建。
3.1 阶段契约
| 阶段 | 输入 | 输出 | 每一步都必须成立的条件 |
|---|---|---|---|
| S0 获取并留痕 | 本地路径或 URL | 原子写入的不可变 SourceArtifact:原始字节、媒体类型、来源元数据、获取状态和 artifact 引用 | 来源可读;编码、响应类型、重定向和体积在限制内;原始输入先落盘,后续变换不得覆盖 |
| S1 提取 | SourceArtifact | 原子写入的不可变 ExtractedDocument:候选标题、语言、按原文顺序排列的语义节点、提取告警和 extraction_hash | 媒体类型有对应适配器;首版仅 HTML;导航、脚本和页脚被去除;正文不足则失败 |
| S2 建档 | SourceArtifact 元数据 + ExtractedDocument | DocumentRecord + DocumentVersionRecord:稳定 doc_id、文档版本、标题、来源显示名、别名和语言 | source_key 稳定映射到 doc_id;同来源同提取内容幂等;内容变化才递增版本;不提前生成章节或 block |
| S3 建树 | DocumentVersionRecord + ExtractedDocument | 不可变 SectionTree:唯一 section_path、父子关系、原文顺序、节点范围和告警 | 文档标题不能重复建节;每个正文节点归属唯一章节;重名路径唯一化;无标题时生成根节 |
| S4 切块与大小估算 | SectionTree + 直属语义节点 + ChunkSizingPolicy | 不可变 BlockSet:有序 block、字符偏移、内容 hash、近似 token 提示和 SectionStats | block 不跨章节、不重叠、不遗漏;使用固定 estimator;约 450 token 是默认目标,不是跨模型精确硬约束 |
| S5 建索引 | BlockSet + 文档/章节上下文 + Qwen embedding 配置 | IndexBundle:dense 向量、关键词索引、embedding 输入 hash、实际 API usage 和构建错误 | embedding 输入由 block 派生且不改原文;按模型批大小调用;dense 与 BM25 必须来自同一批 block |
| S6 封装与发布 | 文档、章节、block、索引、版本和当前活动指针 | candidate manifest、发布记录和原子切换后的 ActiveCollectionPointer | 先完成 manifest/artifact/索引对账,再 CAS 切换指针;失败不动旧快照;只有 ready/ready_partial 可发布 |
这张表最重要的不是七个名字,而是每行的“输出”和“必须成立的条件”。前者定义下一阶段能消费什么,后者决定什么时候必须失败。
3.2 数据流不是一条直线
本地 HTML / URL
└─ S0 获取并留痕 ── SourceArtifact
├─→ S1 提取 ── ExtractedDocument
│ ├─→ S2 建档:正文 hash、标题、语言
│ ├─→ S3 建树:有序标题与正文节点
│ └─→ S4 切块:直属语义节点
├─→ S2 建档:来源元数据
└──────────────────────────────→ S6 对账:源 artifact/hash
S2 DocumentVersion + ExtractedDocument
└─→ S3 SectionTree
├─→ S4 BlockSet:章节边界与直属节点
├─→ S5 IndexBundle:文档/章节上下文
└──────────────────────────────→ S6 对账:章节树
S4 BlockSet
├─→ S5 IndexBundle:同一批 block 生成 dense + BM25
└────────────────────────────────→ S6 对账:block 顺序/数量/hash
S5 IndexBundle
└────────────────────────────────→ S6 对账:向量与关键词索引
当前 ActiveCollectionPointer
└────────────────────────────────→ S6:CAS 切换旧/新快照
S6 manifest 对账通过
└─→ 新 ActiveCollectionPointer
(新请求读新快照;旧请求继续固定旧快照)
关键点是:前面阶段产出的对象不会只供紧邻阶段使用。 S1 的 ExtractedDocument 同时服务 S2、S3、S4;S3 的 SectionTree 同时服务 S4、S5、S6;S6 则要汇合文档、章节、block、两路索引、版本和当前指针,做一次完整对账。
| 复用产物 | 后续消费者 | 为什么不能只传给下一阶段 |
|---|---|---|
SourceArtifact | S1、S2、S6 | S1 读取正文,S2 取来源身份,S6 核对原始来源与 hash |
ExtractedDocument | S2、S3、S4 | S2 定版本,S3 建树,S4 直接拿有序语义节点 |
DocumentVersionRecord | S3、S6 | S3 消费正文版本,S6 在 manifest 中固定版本身份 |
SectionTree | S4、S5、S6 | S4 保章节边界,S5 组 embedding 上下文,S6 对账章节完整性 |
BlockSet | S5、S6 | S5 建双索引,S6 核对块顺序、数量、覆盖与 hash |
IndexBundle | S6 | 只作为候选索引;对账通过后才允许发布 |
3.3 顺序为什么不能随意调整
- 原始输入必须先落盘,后面每一步都能回到来源;
- 正文根必须先于章节树,否则导航栏会混进知识结构;
- 文档身份必须先于 block ID;
- 章节树必须先于切块,否则 block 可能跨节;
- 两路索引必须基于同一批 block,再发布快照;
- 旧快照在新快照发布前始终可用。
4. S0/S1:先得到可信来源,再解释正文
4.1 S0 只负责“拿到并留痕”
本地文件和 URL 是两种获取适配器,输出同一种 SourceArtifact:
- 原始字节;
media_type;source_key;- 获取时间和最终 URL;
- content hash;
- 获取错误和重定向链;
- artifact 引用。
URL 通道要多守几条边界:
- 只允许 HTTP/HTTPS;
- 不允许 Cookie、表单提交和页面脚本;
- 阻止 localhost、私网和链路本地地址,每次重定向后重查;
- 限制响应体积、连接时间、总时长和重定向次数;
- 非 HTML、登录/反爬页、空正文、JS 空壳都必须分类失败。
“空文档”不是合法兜底。它会把网络问题变成检索问题,后面很难查。
4.2 S1 输出格式中立的正文结构
S1 不直接把 HTML 压成文本,而是输出 ExtractedDocument:
- 候选标题与来源;
- 语言;
- 有序语义节点;
- HTML 专属
source_locator; - 提取告警;
extraction_hash。
节点类型至少包括:
heading / paragraph / list / quote / code / table / figure
HTML DOM 只活在这个适配器内部。后续 S2–S6 不应该知道输入原本是 <h2>、<ul> 还是 <pre>。这样以后增加 Markdown 或 PDF 时,复用的是同一条文档、章节、block 和快照骨架。
5. S2/S3:身份稳定,结构才稳定
5.1 文档身份与版本不是一回事
source_key决定“它是不是同一来源”;doc_id是稳定身份;extraction_hash决定正文是否真的变了;doc_version只在提取后的有效内容变化时递增。
重新抓取后如果只是导航、广告和 DOM 包装变化,但标题、语言和正文语义节点完全相同,就不应该制造新文档版本。反过来,解析器升级后正文变了,hash 必须变。
S2 的写入必须是一次单机数据库事务:先查身份,再决定创建还是追加版本。否则重复点击就可能产生两篇“同源文档”。
5.2 S3 不猜标题,只恢复可检查的层级
章节树必须满足:
section_path唯一;- 每个正文节点只归一个直属章节;
- 父子关系和原文顺序稳定;
- 重名标题被稳定消歧;
- 无法识别章节时生成根节,而不是让文档不可检索。
这里不调用模型“猜哪段像标题”。标题节点、级别和顺序已经是可检查证据;让模型补结构会把可复现的解析结果变成模型输出。
6. S4:块的边界由语义决定,不是 450 决定
block 是最小可检索、可引用、可枚举的证据单位。它要同时满足:
- 不跨章节;
- 不重叠;
- 不遗漏;
- 有稳定顺序;
- 能回到规范正文的字符区间;
- 不因 embedding 前缀或摘要被悄悄改写。
S4 分三步:
- 语义初切:段落、列表、代码、表格、图片说明保持完整边界;
- 相邻合并:只合并同章节的小单元,向默认约 450 token 的 sizing 目标靠近;
- 超限拆分:只拆仍然过大的语义单元,优先保留可读边界。
6.1 大表格单独处理
表格是最容易被固定窗口切坏的证据。首版规则是:
- 整表不超过
TableAtomicMax(暂定 4,000 sizing token)时保持一个 block; - 超过上限后按连续逻辑行组分块,不拆单元格;
- 拆出的块携带
table_context:caption、可靠表头和行组序号; - 单行仍然过大时保留原子行并告警,不为满足阈值强拆;
- 行组必须连续、不重叠,并能回到同一个原表节点。
这不是“表格特殊照顾”,而是承认表格的语义单位本来就是行列关系。
6.2 为什么不给每个块保存多套模型 token profile
约 450 token 是 soft target,用于切块和范围规划。真正进入模型前,Harness 会按本次工具实际 payload 计算上下文预算。
如果为每个历史 block 保存多个模型的精确 token 数,模型切换、同系列升级或 tokenizer 变化都会触发大规模重算;收益却很小,因为块大小本来就允许误差。更稳的做法是:
- 入库只保存固定 estimator 的近似提示;
- 运行时对本次实际 payload 精确计算;
- 两者都记录版本,不混成同一种事实。
7. S5:同一批 block,两路索引
首版 embedding 固定为阿里云百炼 qwen3.7-text-embedding:
- dense 输出;
- 默认 1024 维;
- 单条上限 128,000 token;
- 单批最多 20 条;
- 入库文本使用 document 任务类型,查询使用 query 任务类型。
参数与调用方式以阿里云百炼向量化文档与同步接口文档为准。
🤔 推断:因为默认块目标只有约 450 sizing token,远低于模型单条 128k 上限,所以不需要在切块后逐块预检 embedding 输入是否超限。真正需要记录的是批次大小、输入 hash、实际 usage、重试和失败。
关键词通道则使用固定 analyzer 的 BM25。dense 和 BM25 必须:
- 基于同一
BlockSet; - 带同一个 collection 快照 ID;
- 使用固定版本与配置;
- 生成完成前都不能被检索请求选中。
scan_term 走另一条路:直接在规范 block 正文上做 NFC+大小写折叠后的字面扫描。关键词检索零命中不能替代字面扫描,字面扫描也不负责同义词扩展。
8. S6:manifest 对账之后才允许“发布”
发布前至少核对:
- 文档、章节、block 数量;
- block 顺序与字符区间无空洞、无重叠;
- dense 维度与 block 数;
- BM25 文档数;
- artifact 与 checksum;
- 失败文档清单;
- 模型、解析器、切块器与索引版本。
全部通过后:
- 写不可变 candidate manifest;
- 在一个数据库事务中 CAS 切换活动快照指针;
- 新请求读新快照;
- 进行中的请求继续固定旧快照;
- 旧快照在无引用且过保留期后清理。
快照有四种状态:
| 状态 | 含义 | 可用于问答 |
|---|---|---|
building | 仍在构建 | 否 |
ready | 全部目标文档成功 | 是 |
ready_partial | 部分文档成功,失败清单明确 | 是,但必须披露缺口 |
failed | 没有形成可用快照 | 否 |
如果 manifest 对账不通过,正确动作是失败并保留旧快照,而不是挑一部分“看起来没问题”的 block 发布。
9. 质量属性复核:入库层还要守住什么
| 质量属性 | 入库侧的设计响应 |
|---|---|
| 正确性/完整性 | 单篇原子成功、无损覆盖、manifest 对账 |
| 可靠性 | URL/解析/模型失败分类;可重试与不可重试分开 |
| 一致性 | 固定解析、切块、estimator、analyzer 版本 |
| 可观测性 | 每个阶段有状态、数量、耗时、usage、错误和 artifact |
| 可复现性 | 原始输入留痕;快照不可变;版本清单完整 |
| 性能 | 后台分批;可暂停续跑;索引完成前不发布 |
| 安全 | 文档是不可信输入;URL 防 SSRF;不执行页面脚本 |
首版明确后置两项:完整入库 trace 的常驻查看/保留,以及磁盘生命周期管理。它们影响体验,但不阻塞先把检索做对、做快。
10. 一页速查
目标:产出不可变 collection 快照,而不是一串 chunk。
主链:S0 留痕 → S1 语义节点 → S2 身份版本 → S3 章节树 → S4 block → S5 双索引 → S6 manifest/发布。
四条硬约束:
- 原始输入先落盘;
- 文档不残缺上线,批次允许 partial;
- block 不跨节、不重叠、不遗漏;
- manifest 对账通过后才切活动指针。
切块:段落/列表/代码先保语义边界,再向约 450 sizing token 合并;大表格优先整表,超限才按行组拆。
索引:同一批 block 生成 dense+BM25;scan_term 只做原文扫描。
一句话:入库决定检索能不能“取对、取全、回得去”;切块只是其中一步。
下一篇:《文档问答 Agent 的评测台:从跑分页到可诊断、可回归的系统》
完整设计记录:0058 HTML 入库设计(HTML 源文件)
前置阅读: