指南

把泰语 PDF 建成搜索索引:OCR、文本分块与缺失的空格

当文本没有空格可供切分时,如何把扫描的泰语文档切成搜索索引或语言模型可用的文本块。

· 阅读约 1 分钟

企业想要检索的泰语源文档,大多是扫描件。政府通函、合同、董事会会议纪要和较早的年报,都是纸质文件的 PDF,带文本层的那些也常常是坏的。页面读出来之后,词与词之间没有空格,句与句之间也没有句号,为英语编写的分块程序会在错误的位置切开。

泰语文档从扫描件变成索引中的文本块,要经过五步。按结构读取页面,弄清固定窗口为什么不适用于泰语,选择切分位置,决定每个文本块要附带什么,并在建索引前做规范化。示例用 Paxa OCR Lite 读取页面。分块方法适用于任何能返回结构的识别程序。

扫描件与损坏的文本层#

泰语 PDF 分两种。扫描件里完全没有文字,页面上的每个字符都必须从像素中读出。原生数字 PDF 带有文本层,而泰语文本层出错的方式,提取指南已经一一列出:声调符号存储顺序错乱,旧字体把字节映射到错误的字符,各栏内容交错返回。两种情况殊途同归,页面必须当作页面来读。

先尝试文本层、失败后再回退到 OCR 的流水线,需要有办法判断是否失败。损坏的泰语文本层,除非由懂泰语的人来读,否则在任何检查下都像正常文字。每一页都用同一种方式读取,每页花几个积分,也省掉了这个分支。

按结构块读取文档#

在读取这一步就要求返回结构。把 output 设为 "structured",POST /v1/ocr 会按阅读顺序把每一页返回为带类型的结构块。这些块包括带层级的标题、段落、带条目的列表、由单元格行组成的表格,以及带说明文字的图片。下面几行就是一页规章返回的那类结构块。

以结构块表示的一页
{ "type": "heading", "level": 1, "text": "ระเบียบการเบิกค่าเดินทาง" }
{ "type": "heading", "level": 2, "text": "หมวด ๒ อัตราค่าพาหนะ" }
{ "type": "paragraph", "text": "ข้อ ๗ การเดินทางโดยรถไฟให้เบิกได้ตามที่จ่ายจริง ..." }
{ "type": "table", "rows": [["ประเภท", "อัตรา (บาท/กม.)"], ["รถยนต์ส่วนตัว", "๔"], ["รถจักรยานยนต์", "๒"]] }

一次请求最多读取 50 页、10 MiB,更长的文档需要分段发送。各段之间的页码要保持连续,因为引用指向的就是页码。Markdown 输出包含同样的读取结果,结构以标题标记和竖线表格的形式呈现,已经能解析 Markdown 的流水线可以直接从这里开始。用结构块则连解析这一步都省了。

固定窗口为什么不适用于泰语#

常见的分块程序取一个几百字符的窗口,带一定重叠向前滑动。它默认靠近空格或句号的切分点也靠近语义边界。英语大约每五个字符就有一个空格,每二十个词就有一个句号。泰语每个分句才有一个空格,词与词之间什么都没有。按字符数结束的窗口,多数时候会停在词中间,几乎每次都会停在句子中间。

重叠掩盖了损坏,却没有修复它。被切成两半的词会在下一个文本块里完整出现,留在这个文本块里的半截则是任何查询都匹配不到的字符串。被拆到两个文本块里的句子,主语在一块,结论在另一块,检索只返回其中一块时,得到的是一个什么都回答不了的片段。

按 token 计数的分块程序也有同样的问题,只是隔了一层。token 边界由分词器决定,它和字符计数一样,不知道泰语词在哪里结束。

按识别程序返回的结构切分#

文档本身已经带有边界。标题开启章节,段落是作者自己划定的意义单元,表格是一个整体。按结构块遍历的分块程序靠三条规则就能工作,用不到泰语词典。

  • 默认以段落为一个文本块。同一标题下相邻的短段落可以合并,直到达到大小上限。合并遇到标题即停止。
  • 一张表格整体作为一个文本块,并保留表头行。单独一行会丢失列名,按大小拆开的表格会丢失回答问题的那些行。
  • 放得下的列表作为一个文本块。放不下时,在条目之间拆分,每个条目保持完整。

单个段落本身就超过上限时,才需要在正文中间切分。在泰语的空格处切,空格至少是分句边界,往往也是句子边界;优先选择低于上限且离上限最近的那个空格。这样能保持分句完整,也绝不会切在词中间。分词文章解释了为什么在泰语里,切在词中间比切在句子中间更糟。

大小上限取决于读取文本块的模型,以及每次查询返回的文本块数量。上限以泰文字符数设定,并在实际语料上测量。一个泰文字符承载的单词内容比一个拉丁字母多,按英文文本调好的上限,用在泰语上就偏松了。

附带标题路径和页码#

同一段文字放在 หมวด ๒ อัตราค่าพาหนะ 下和放在 หมวด ๓ 下,含义并不相同。生成向量时,在每个文本块的文字前加上它的标题路径,同时把路径存成一个字段用于显示。页码也要存。问某条规定出自哪里的用户需要页码,核对读取结果的审核人员也需要直接打开那一页扫描件。

索引中的一个文本块
{
  "doc": "travel-regulation-2569.pdf",
  "page": 3,
  "trail": ["ระเบียบการเบิกค่าเดินทาง", "หมวด ๒ อัตราค่าพาหนะ"],
  "text": "ข้อ ๗ การเดินทางโดยรถไฟให้เบิกได้ตามที่จ่ายจริง ...",
  "match": "ข้อ 7 การเดินทางโดยรถไฟให้เบิกได้ตามที่จ่ายจริง ..."
}

在规章或合同里,ข้อ ๗ 这样的条款编号值得单独设一个字段,因为查询常常直接点名条款。识别程序按印刷原样返回编号,流水线再从段落开头把它读出来。

规范化一份匹配副本,保留原文#

泰语文档里,同一个值往往有好几种写法,而查询只用其中一种。为每个文本块另建一份用于匹配的副本,原文留作显示和引用。

  • 泰文数字转为阿拉伯数字。๒๕๖๙ 和 2569 是同一年,一份文档的同一页上就可能混用两种写法。
  • 佛历年份按印刷原样保留,公历年份另存一个字段。直接在文本里减去 543 会破坏引用。
  • 元音 อำ 统一为一个码位。有些文档把它存成 นิคหิต 加 สระอา,Unicode 规范化不会处理这种情况,两种形式永远匹配不上。
  • Unicode NFC,用来修正损坏的文本层造成的叠加符号顺序错乱。
  • 连续空白合并为一个空格,并删除零宽字符。识别程序不会返回零宽字符,文本层却会返回很多。

对查询也做同样的规范化。页面上明明看得见的词却搜不到,最常见的原因就是两边不一致。

词法索引、向量索引,还是两者结合#

向量索引对文本块做嵌入,不需要词边界。嵌入模型直接读取字符,上文的分块边界是唯一需要针对泰语做的决定。词法索引存储的是词项,而词项就是词。因此,泰语词法索引在文本块和查询两侧都需要分词器,两侧必须用同一个,换分词器时还要重建索引。

精确字符串正是词法索引的用武之地。条款编号、文档 ID、税号、金额和名称,向量索引会把它们模糊化,词项索引则能准确命中。同时给两边打分并合并结果列表的混合方案,能很好地处理规章,而词法一侧索引的正是规范化后的副本。

读取一份五十页的报告要多少钱#

不论页面上有什么内容,每页都是 6.5 积分,而且扣费前就能知道页数。一份 50 页的报告需要 325 积分,折合 $0.33。文档只读一次,分块规则一变,就用存下来的结构块重新生成文本块。只有页面本身改变时,才需要重新读取。

除了文本块,结构块也要存下来。调优索引的过程中,文本块大小上限、标题路径格式和规范化规则都会变,每次变化都可以从存储的结构块免费重建。

下一步#

继续阅读

更多指南