一个合法 HTML 注释,为什么能让 RAG 入库失败——从 HtmlComment 异常看解析器边界、错误分层与版本升级

1 阅读10分钟

30 秒判断:HTML 不是“只有标签的漂亮树”。注释、CDATA、脚本、错误嵌套和 tail 文本都是合法输入。解析器只要在某个遍历分支里假设“每个节点都是 element”,一条正常网页就可能让整篇文档入库失败。更麻烦的是,修完解析逻辑还不算结束:解析行为变了,内容 hash、索引语义和旧快照兼容性也随之变化。

适合谁读:正在做 HTML 抽取、正文清洗、文档切块和 RAG 入库管线的开发者;遇到过“网页在浏览器正常,但后端解析奇怪报错”的人;正在处理 parser/chunker 升级与旧索引兼容的人。

系列位置:本文属于《学习问答助手Agent实践》的实现复盘部分。系列前 9 篇完成了需求定义、架构设计与评测方法;进入实现后,第一篇《AI 写代码越快,为什么我反而越没有掌控感?》讨论控制模型,上一篇增量发布故障复盘讨论活动 collection 如何被错误覆盖。本篇不重复HTML 到 collection 的总体设计,而是拆开一个真实解析故障,观察“单行判断错误”如何一路影响到快照版本。

代码库:ai-study-helper-v2

结构速览:§0 故障现场;§1 复现与根因;§2 为什么只跳过注释还不够;§3 正确修复;§4 错误应该怎样分层;§5 为什么必须提升解析器版本;§6 测试策略;§7 一份 HTML 解析防御清单;§8 协作复盘;§9 一页速查。

证据标记:✅ 实测=真实文件复现;🧪 回归=已有自动化测试固定行为;🤔 推断=由当前解析与快照机制推导出的结论。


0. 故障现场

一次批量 HTML 入库中,5 个来源里有 4 个成功,最后一个失败。

界面给出的错误是:

Input object is not an XML element: HtmlComment

批次状态仍然是“部分入库”:

  • 成功的文章已经在活动知识库里;
  • 失败文章没有进入索引;
  • 界面能指出第 5 个来源失败;
  • 但错误信息看起来像 Python 内部异常,普通用户无法据此采取行动。

这里同时出现了三个问题:

  1. 一个合法 HTML 文档没有被解析;
  2. 内部实现异常直接泄漏给了用户;
  3. “来源本身有问题”和“解析器有缺陷”没有被区分。

第一层是解析 bug,第二层是错误语义问题。


1. 复现与根因

用原文件单独执行预览,异常能稳定复现。

调用栈最终落在一个列表项处理函数里,简化后相当于:

for item in list_element.xpath("./li"):
    own_text_parts = []
    if item.text:
        own_text_parts.append(item.text)
    for child in item:
        own_text_parts.append(child.text_content())

HTML 中出现了:

<li>
  第一段文字
  <!-- 编辑器注释 -->
  注释后的文字
</li>

在 lxml 中:

  • 普通标签是 element;
  • HTML 注释是 HtmlComment;
  • HtmlComment 的 tag 不是普通字符串;
  • 对它调用 text_content() 会抛出:
ValueError: Input object is not an XML element: HtmlComment

更隐蔽的是,主文档遍历函数已经写过元素过滤:

if not isinstance(child.tag, str):
    continue

但列表项内部还有一套自己的子节点遍历,它漏掉了同样的防御。

✅ 实测:真实文件稳定复现,移除注释后可正常通过,确认根因就是遍历分支没有过滤非元素节点。

为什么容易漏

这是典型的“多个局部遍历各自实现一遍”问题:

  • 主遍历知道要过滤脚本、样式和非元素节点;
  • 列表遍历只记得处理嵌套 <ul>/<ol>;
  • 两个地方语义相似,却没有共享同一套元素判断。

它不是因为注释少见。真实网页里,编辑器信息、组件边界、A/B 标记、服务端模板和埋点都可能产生 HTML 注释。

从人机分工看,AI 很适合这一步:读取堆栈、定位到 HtmlComment、提出最小防御判断。但“直接跳过注释”只是局部修复。

人的关键价值是继续追问系统边界:

  • 注释之后的可见文字还在不在?
  • inline 元素是否也漏了 tail?
  • 这个异常应该算来源问题还是解析器问题?
  • 行为变化后 extractor 版本是否还能保持不变?

这也是后文所有检查存在的原因:AI 收集证据并实现修复,人负责把单点 bug 上升为必须满足的内容和版本契约。


2. 为什么“直接跳过注释”还不够

最直观的修复是:

if not isinstance(child.tag, str):
    continue

这能避免崩溃,但可能悄悄丢正文。

因为在 HTML DOM 中,一个节点后面的文字常放在它的 tail 上:

<li>前文<!-- 注释 -->后文</li>

结构关系近似于:

li.text = "前文"
comment.tail = "后文"

如果直接跳过 comment,后面的“后文”也会一起消失。

同样的问题也存在于普通 inline 元素:

<li>前文<strong>重点</strong>后文</li>

只读取 strong.text_content() 得到“重点”,却不读取 strong.tail,就会丢失“后文”。

因此在列表项中,正确的文本归属要同时考虑:

  • 当前元素的 text;
  • 子元素内部的 text_content();
  • 子元素的 tail;
  • 嵌套列表与父列表的边界。

3. 正确修复

最终实现按下面的逻辑收集列表项直属文本:

for child in item:
    if not isinstance(child.tag, str):
        if child.tail:
            own_text_parts.append(child.tail)
        continue

    if child.tag in {"ul", "ol"}:
        if child.tail:
            own_text_parts.append(child.tail)
        continue

    own_text_parts.append(child.text_content())
    if child.tail:
        own_text_parts.append(child.tail)

这段修复包含两个判断:

  1. 非 element 节点不能调用 element API;
  2. 节点被跳过不等于它的 tail 也应该消失。

🧪 回归:新增测试同时覆盖注释前文字、注释后文字、inline 元素和 inline tail,并断言注释内容不进入正文。

为什么不直接在解析前删除所有注释

也可以先把注释从 DOM 中移除,但需要保证删除节点时正确合并 tail。否则只是把同样的文本丢失问题换了一个位置。

更稳妥的原则是:

抽取层显式定义“哪些节点贡献文本”,而不是依赖 DOM API 对异常节点做隐式处理。


4. 错误应该怎样分层

相同现象背后至少有三类错误:

错误类型例子面向用户的语义
输入错误空内容、登录页、JS 空壳这个来源不适合当前能力
上游错误网络超时、429、embedding 服务不可用暂时性失败,可重试
内部错误parser 未处理合法节点、状态不一致系统缺陷,需保留现场排查

本次故障一开始只是被记成通用的:

source_ingestion_failed

这会让界面错误地暗示“这个来源有问题”,而真正的责任在解析器。

修复时把未知解析异常映射为稳定的内部错误码,例如:

extractor_internal_error

同时记录完整服务端日志,但不把 Python 原始异常直接当成用户说明。

原则:未知异常必须保留技术细节,但对产品暴露稳定、可分类、可操作的错误语义。

不能为了“界面好看”而吞掉异常。内部错误码的意义是明确告诉系统:这不是用户文档不够好,而是解析实现需要修。


5. 为什么必须提升解析器版本

HTML 注释问题最初看起来只是一个防御性判断,但它改变了抽取行为:

  • 以前遇到注释会失败;
  • 修复后能生成完整章节和 blocks;
  • inline tail 也开始进入正文;
  • 同一原始 HTML 可能得到不同的 extraction_hash。

因此不能继续宣称“解析器版本 1 的行为没有变化”。

本次把 extractor 从 v1 提升到 v2。

版本提升不是形式主义

它至少影响三件事:

  1. 可复现性:同一来源、同一版本必须产生同一结构;
  2. 问题定位:用户报告解析差异时,必须知道当时用的是哪个版本;
  3. 快照兼容性:同一 collection 中的文档通常要共享一组解析、切块和 embedding profile。

如果旧知识库由 extractor v1 构建,新导入文档由 v2 构建,直接把两批混进同一个 collection,看似只是多加了几篇,实际上 manifest 已经无法用单一版本准确描述全部内容。

这时有两种诚实选择:

  • 重新构建整个 collection;
  • 明确引入分文档版本并重新设计快照语义。

不能悄悄把两个版本混在一起。

🤔 推断:任何会改变正文边界、节点顺序、文本内容或 hash 的解析修复,都应触发版本升级评估。


6. 测试应该怎么补

6.1 最小合成用例

<ul>
  <li>
    第一段
    <!-- editor note -->
    注释后的文字
    <strong>重点</strong>
    收尾文字
    <!-- nested note -->
  </li>
</ul>

断言:

  • 不抛异常;
  • 注释内容不进入正文;
  • 注释前后的可见文字都保留;
  • inline 元素内部及 tail 都保留。

6.2 真实语料验证

合成用例证明分支逻辑,真实文件证明它在页面整体结构中成立。

修复后,原先失败的文件成功得到:

31 sections
34 blocks
67 semantic nodes

✅ 实测:真实文件完成完整单篇入库,不只是 preview 返回成功。

6.3 错误语义测试

注入一个模拟的 extractor 内部异常,断言接口返回稳定错误码,而不是 500 堆栈或通用来源失败。

6.4 全量回归

解析器版本变化后,还要回归:

  • section tree;
  • block 边界;
  • 双索引;
  • collection 发布;
  • 详情页正文;
  • 旧快照版本判定。

这份回归清单本身就是控制契约:只要解析行为发生变化,负责人就应该能快速判断哪些内容、错误和版本边界也被一起改变了。


7. 一份 HTML 解析防御清单

面对不可信 HTML,至少检查:

  • 注释、CDATA、script、style 和 noscript;
  • 非字符串 tag 的节点;
  • 元素 tail 是否被遗漏;
  • inline 元素前后的可见文字;
  • 表格单元格中的嵌套结构;
  • 重复标题和非法标题跳级;
  • 未闭合标签被解析器自动修复后的结构;
  • 不可见字符和错误编码;
  • extractor 内部异常与输入错误的分类;
  • parser/chunker 版本变化后的旧快照兼容。

其中最容易忽略的往往不是“复杂网页”,而是:

一个本来合法、但代码没想到的普通节点

8. 协作复盘

这个案例最后形成的协作结论不是“AI 修得快”,而是两种能力缺一不可:

  • AI 负责快速复现、追踪节点类型、实现遍历修复和补测试;
  • 人负责追问 tail 文本、错误分类、版本语义和旧快照兼容;
  • 合成用例证明分支逻辑,真实文件证明整页语义;
  • 修复完成后继续做完整入库,而不是停在 preview 成功。

如果只检查“异常是否消失”,这个 bug 很容易被当成一行 if 修复。最终能够形成可复用经验,是因为人没有把运行成功当作系统正确。


9. 一页速查

故障:HTML 注释让列表遍历调用 element API,整篇文档入库失败。

根因:主遍历过滤了非 element,列表遍历漏了过滤。

修复不能只做一半:

  • 跳过 HtmlComment;
  • 保留注释后的 tail 文本;
  • 同样处理 inline 元素 tail;
  • 未知解析异常映射成稳定内部错误码。

版本影响:

  • 抽取行为变化;
  • extraction hash 可能变化;
  • extractor 必须升级;
  • 旧 collection 不能默默混入新版本。

测试闭环:

合成注释列表
  + 真实网页
  + 错误语义
  + 版本兼容
  + 完整发布回归

最重要的一句话:

合法输入触发崩溃时,要修的不是一个 if,而是解析器的节点边界、文本归属、错误分类和版本契约。