可将客户支持文档包合并以供审阅,再拆分以便升级处理,因此当前 PDF 中显示的页码并非持久的引用位置。**按源页面为文本编制索引,在每次文档包操作中保留该身份,并仅在呈现边界处将其转换为渲染后的页面。**这样,即使模板更换所有者,检索证据仍然清晰可解释。
简而言之:为 PDF 的每一页提取一条有序文本记录,附加不可变的文档 ID 和源页码,将每次合并或拆分记录为映射,而不是覆盖这些坐标,并在每个结果中同时返回稳定的源坐标和当前显示坐标。Node.js 服务可以调用这个索引边界;契约比运行时更重要。
这种类似事故的故障很容易在仪表板上被忽略。一名支持专员搜索了一个文档包,其中包含客户来信、故障排查记录和退货授权。结果显示第 14 页。之后,运营人员将退货文件拆分成单独的文档包,第 14 页现在成了别的内容。搜索状态仍为绿色,延迟仍然平稳,但引用已经失实。究竟是哪一页触发了结果:结果中标出的那一页,还是实际包含该句子的源页面?
PDF 是一种分页文档格式,但拼装文件中的序号位置并非业务标识符。合并两个文件后,第二个文件中的每一页都会移位。插入封面后,几乎所有可见页码都会移位。拆分文档包后,同一个源页面可能会变成新文件中的第 1 页。提取出的文本可能仍有用,但定位信息会悄然失效。
它会悄无声息地失效。
模板归属会让问题更严重。在客户支持中,一个团队可能负责信函模板,另一个团队负责退货或合规表单。如果文档打包流程只把最终 PDF 当作记录,模板团队一次无害的插入,就会改变其并不拥有的内容的检索坐标。所有权应止于源文档边界;拼装过程负责管理映射,而非源身份。
这条不变量很简单:(source_document_id, source_page)永不改变。生成的文件有自己的 ID,并带有一份将每个显示页面映射回该文档与页面组合的映射。搜索分块继承这个稳定组合。界面可以显示Bundle page 14,但审计记录仍必须注明引用文本来自哪个源文档和页面。
将提取、拼装和检索分开。提取过程输出有序的页面记录。拼装过程输出沿袭关系。检索过程存储带有页面级溯源信息的分块,并在缺少溯源信息时拒绝编造引用。
type PageRecord struct {
SourceDocumentID string
SourcePage int // One-based, as shown to reviewers.
Text string
TextSHA256 string
}
type DisplayPage struct {
ArtifactID string
DisplayPage int
SourceDocumentID string
SourcePage int
}
type Citation struct {
SourceDocumentID string
SourcePage int
ArtifactID string
DisplayPage int
Quote string
}
不要使用提取出的文本作为身份标识。规范化或模板修订可能会改变文本,而重复的固定文本可能会出现在多个页面上。摘要值可以检测提取结果是否发生变化,但文档 ID 和页序号才是定位信息。
对于 Node.js 接收端点,让边界接收 PDF 和调用方提供的文档身份,然后在分块之前生成页面记录。不要把文档展平成一个字符串,再事后重建分页符。提取器必须保留页面顺序,并将没有可提取文本的页面表示为显式记录;否则第 8 页可能会被丢弃,导致第 9 页被误标为第 8 页。
预防性检查应放在已拼装文件的注册环节。它应拒绝重复的显示页面、页码间断、非正序号,以及对提取台账中不存在页面的引用。
func ValidateAssembly(pages []DisplayPage, known map[string]map[int]bool) error {
seen := make(map[int]bool, len(pages))
for _, p := range pages {
if p.DisplayPage < 1 || p.SourcePage < 1 {
return fmt.Errorf("page ordinals must be positive")
}
if seen[p.DisplayPage] {
return fmt.Errorf("duplicate display page %d", p.DisplayPage)
}
if !known[p.SourceDocumentID][p.SourcePage] {
return fmt.Errorf("unknown source page %s:%d", p.SourceDocumentID, p.SourcePage)
}
seen[p.DisplayPage] = true
}
for n := 1; n <= len(pages); n++ {
if !seen[n] {
return fmt.Errorf("missing display page %d", n)
}
}
return nil
}
很好。
凌晨 3 点,已索引分块数量只是薄弱的证据;有用的页面会指出某个文件引用了return-form:3,尽管提取过程只生成了两页。应针对被拒绝的沿袭关系、持续的提取失败,以及无法解析映射的引用发出警报。页面应指出失效的不变量和受影响的文件。
重试也需要稳定的键。同一源修订版被索引两次时,应替换或确认相同的页面记录,而不是创建并行身份。即使文件名未变,新的模板修订版也应获得新的源修订身份。否则,较晚的重试可能会把旧文本关联到新映射。
一次顺利路径的搜索测试几乎不能证明什么。围绕转换构建测试夹具:将三页的对话记录与两页的授权文件合并,插入一页封面,将第 2–4 页拆分到新文件中,并重新排列文档包。断言每个显示页面都恰好映射到一个已知源页面,并且引用结果能追溯到相同的提取页面文本。有些测试中,可见页码应该变化;源坐标必须保持不变。
也要使用格式异常的测试夹具:提取器无法打开的受保护文件、没有嵌入文本的扫描页面、重复的固定文本,以及漏掉一页的映射。预期行为应是明确失败或处于extraction_required状态,绝不能给出看似确定的空结果。OCR 是一种独立的提取模式,应记录这一区别,因为它提取出的文本可能不同于嵌入文本;引用仍然指向审阅者可以检查的页面图像。
部署应围绕数据契约进行版本管理。更改提取或分块方式时运行影子索引,比较页数和无法解析的映射,然后仅在新索引通过沿袭检查后切换读取。旧索引应保留足够长时间,以解析已发出的引用。这会增加存储和运维投入,但可避免提取升级变成无法追溯的证据改写。
如果文件不可变、永不拆分,并且引用只需在该确切文件有效期内存在,那么只为最终拼装的 PDF 编制索引是可以接受的。这是最简单的模型。但如果支持工作流以不同顺序重新发布相同文档,它就会失效。
将页面标签嵌入分块文本可能会改善检索上下文,但这并不能确立沿袭关系;标签可能重复、遗漏,或由模板生成。仅存储边界框可以在页面内实现精确高亮,但仍需要稳定的页面身份。保留源坐标和显示坐标会在读取时增加一次连接。这确实是一种权衡,但这次连接可以测试,而替代做法会掩盖历史。
如果检索单元是具有内容寻址标识符的不可变页面图像,并且不存在重新生成文档包的概念,那么这条建议不适用。在这种情况下,图像身份本身就提供了稳定坐标。对于可变的支持文档包,决策规则很明确:拥有模板的团队可以修订自己的页面,但任何修订都不得改写另一团队的证据身份。
选择提取组件时,应验证它能否为你的测试夹具语料库确定性地输出页面边界、能否显式报告失败而不是静默跳过页面,以及能否固定版本并进行回归测试。选择索引架构时,应考虑在合并、拆分或模板修订之前发出的引用,之后是否仍可解析。名称和功能列表是次要的。
有用的结果应包含引文、稳定的源文档和页面、当前文件和显示页面、提取修订版,以及足以防止用户解析其无权访问文档证据的授权上下文。记录标识符和结果,而非客户文本。除延迟外,还要衡量无法解析的沿袭关系和提取状态数量。
**持久可靠的设计是附带搜索功能的溯源台账,而不是在搜索索引上随意添加页码。**它让文档包变更清晰可见,限定模板所有权的边界,并在引用无法解析时为值班工程师提供一条具体的不变量。
参考资料:
-
- ISO 32000-2,《便携式文档格式》:https://www.iso.org/standard/75839.html