Agentic RAG 的入库设计:从 HTML 到可检索、可复现的快照

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 PDF 论文、白皮书、报告 后续单独处理版面与页码
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 数据流不是一条直线

bash 复制代码
本地 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。

节点类型至少包括:

css 复制代码
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 分三步:

  1. 语义初切:段落、列表、代码、表格、图片说明保持完整边界;
  2. 相邻合并:只合并同章节的小单元,向默认约 450 token 的 sizing 目标靠近;
  3. 超限拆分:只拆仍然过大的语义单元,优先保留可读边界。

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;
  • 失败文档清单;
  • 模型、解析器、切块器与索引版本。

全部通过后:

  1. 写不可变 candidate manifest;
  2. 在一个数据库事务中 CAS 切换活动快照指针;
  3. 新请求读新快照;
  4. 进行中的请求继续固定旧快照;
  5. 旧快照在无引用且过保留期后清理。

快照有四种状态:

状态 含义 可用于问答
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/发布。

四条硬约束:

  1. 原始输入先落盘;
  2. 文档不残缺上线,批次允许 partial;
  3. block 不跨节、不重叠、不遗漏;
  4. manifest 对账通过后才切活动指针。

切块:段落/列表/代码先保语义边界,再向约 450 sizing token 合并;大表格优先整表,超限才按行组拆。

索引 :同一批 block 生成 dense+BM25;scan_term 只做原文扫描。

一句话 :入库决定检索能不能"取对、取全、回得去";切块只是其中一步。


下一篇 :《文档问答 Agent 的评测台:从跑分页到可诊断、可回归的系统》

完整设计记录 :0058 HTML 入库设计(HTML 源文件)


前置阅读:

  1. 《Agent 开发为什么要测试驱动升级版》
  2. 《需求定义四步:用一个文档问答 Agent 演示"先定义、再动工"》
  3. 《金标准:用一个文档问答 Agent 演示"什么算对、怎么判"》
  4. 《由检索需求反推存储》
  5. 《Agentic RAG 检索侧设计:从模式选型到工具、循环与提示词》
  6. 《Agentic RAG 能检索还不够:从质量属性反推错误、预算、Trace 与版本管理》
相关推荐
Topskys1 小时前
大模型原理之投机解码
agent
沛东的认知和实践1 小时前
Agentic RAG 的评测台:从跑分页到可诊断、可回归的系统
agent
qinglongcn1 小时前
Agent 任务的一生——从用户按下发送到任务完成
agent·ai编程
Momo__1 小时前
155个App把豆包关在门外,豆包反手掏出第二把钥匙
app·agent·ai编程
用户9788587019501 小时前
本地 Agent 十次里有两次工具调用失败,原因是它吐出的 JSON 少了个括号——格式校验与有限重试我这样加
llm·agent
阿里云云原生2 小时前
数字员工 or 数字分身?研发端到端交付提效的实践
agent
ikun_文2 小时前
LangChain-常用场景
langchain·agent
阿里云大数据AI技术3 小时前
云栖实录|从数据闭环到模型闭环,阿里云全栈方案助力多模态AI走向生产
人工智能·agent
阿里云云原生3 小时前
AgentTeams Dashboard:统一管理团队、项目与资产
云原生·agent