阿拉伯语/希伯来语同一词有多种不可见写法,直接建索引会导致查询匹配不全。规范化需严格按 NFKC → 去格式符 → 去变音符号 → 统一字母变体 → 兼容性分解的顺序执行,任意两步颠倒结果都会改变。
阿拉伯语和希伯来语都有多种合法的方式书写同一个词,但这些差异对于快速浏览页面的读者来说完全不可见。建立在原始文本之上的搜索索引会为每个变体维护一个独立的倒排列表,因此查询只能匹配作者所使用的特定拼写,无法匹配其他形式。
五个阶段,任意两个交换顺序都会改变结果。顺序之所以重要,是因为每个阶段都依赖于前一个阶段已经消除了一类变体:先统一字母再折叠表现形式,意味着表现形式逃脱了统一处理;先去除标记再做兼容性分解,意味着表现形式连字内部的标记会存活下来。
NFKC。将阿拉伯语表现形式块(U+FB50–U+FDFF 和 U+FE70–U+FEFF)以及希伯来语表现形式(U+FB1D–U+FB4F)折叠为常规字母。从 PDF 中提取的文本通常以表现形式到达,因为那是字体编码所包含的内容。
移除格式字符。双向控制字符、tatweel、软连字符。这些字符没有词义内容。
去除变音符号。阿拉伯语的 tashkeel、希伯来语的 niqqud 和 cantillation 标记。
统一字母变体。Alef 形式、teh marbuta、alef maksura、希伯来语词尾形式、波斯语与阿拉伯语的 kaf 和 yeh。
折叠数字。阿拉伯-印度数字和扩展阿拉伯-印度数字转为 ASCII。
整个管线同样作用于查询端,执行相同的函数。不对称的管线比没有管线更糟糕:它将一个部分匹配问题变成了完全匹配问题。
阿拉伯语短元音用字母上方和下方的标记来书写,在普通 prose 中通常直接省略。宗教文本、诗歌、词典和儿童读物会包含这些标记。因此同一个词既有可能带标记出现,也有可能不带标记出现,而带标记的形式包含了额外的码点,tokeniser 会将其视为 token 的一部分。
Arabic marks to remove
U+064B ً fathatan
U+064C ٌ dammatan
U+064D ٍ kasratan
U+064E َ fatha
U+064F ُ damma
U+0650 ِ kasra
U+0651 ّ shadda
U+0652 ْ sukun
U+0670 ٰ superscript alef
U+0640 ـ tatweel (kashida) — a stretching character, not a letter
Letter variants to unify
U+0622 آ ┐
U+0623 أ ├→ U+0627 ا alef
U+0625 إ ┘
U+0671 ٱ ┘
U+0629 ة → U+0647 ه teh marbuta → heh
U+0649 ى → U+064A ي alef maksura → yeh
Persian-vs-Arabic pairs (pick one target per index)
U+06A9 ک ↔ U+0643 ك keheh / kaf
U+06CC ی ↔ U+064A ي farsi yeh / yeh
alef 统一是其中最有价值的处理。Hamza 在 alef 上标记一个喉塞音,其位置取决于语法上下文,因此同一个词在一句中拼作 U+0623,在另一句中拼作 U+0627,而且很大一部分书面阿拉伯语完全省略了 hamza。如果在索引时将四种 alef 形式视为不同的字母,那么对一个常见词的查询会错过语料库中的大部分内容。
Teh marbuta 是一个需要判断的处理,而非明确的收益。它是阴性结尾,根据上下文发 h 或 t 的音,在非正式场合通常写作普通 heh。将其映射到 heh 可以提高召回率,也会合并一些实际不同的词;保持不变则会将每个阴性名词拆分为两种形式。包括 Lucene 在内的大多数阿拉伯语分析器都会做此映射。请一次性做出决定并记录原因。
如果你的语料库涉及多语言,波斯语字母对是必选的。波斯语键盘输出 U+06A9 和 U+06CC;阿拉伯语键盘输出 U+0643 和 U+064A。视觉上的差异只是词尾位置的一对点点,读者不会注意到,不处理的话会使每个包含常见字母的波斯语文档与每个阿拉伯语文档落入不同的索引空间。
希伯来语面临相同的情况,只是多了一个自己的问题。
Hebrew marks to remove
U+0591–U+05AF cantillation marks (te'amim)
U+05B0–U+05BC niqqud (vowel points), incl. dagesh U+05BC
U+05BD meteg
U+05BF rafe
U+05C1, U+05C2 shin dot, sin dot
U+05C7 qamats qatan
Final forms — five letters change shape word-finally
U+05DA ך → U+05DB כ kaf
U+05DD ם → U+05DE מ mem
U+05DF ן → U+05E0 נ nun
U+05E3 ף → U+05E4 פ pe
U+05E5 ץ → U+05E6 צ tsadi
Punctuation that is not ASCII
U+05BE ־ maqaf (Hebrew hyphen)
U+05F3 ׳ geresh — often typed as ASCII '
U+05F4 ״ gershayim — often typed as ASCII "
词尾形式之所以重要,是因为希伯来语会附加前缀。以 mem 结尾的词单独书写时使用词尾形式,而同一词根后接后缀时,字母会恢复为中间形态。两个不同的码点对应语法上的同一个字母,导致同一词素被索引两次。
将词尾形式折叠为中间形态是常规方向,但这是一个有损操作:מים 以词尾 mem 结尾,假设的中间形态 mem 拼写会是不同的字符串但表示同一个词,因此折叠增加了召回率而不损失搜索者关心的任何东西。只在索引时做此处理而不在查询时做——这是需要避免的错误。
Geresh 和 gershayim 值得特殊处理,因为它们标记缩写词和专有名词缩写,而这恰恰是新闻或法律语料库中高价值的 token。将其规范化为 ASCII 撇号和引号——或者直接去除——都可以,只要查询端得到相同处理,并且你的 tokeniser 不会在该字符处拆分缩写词。
双向文本携带纯粹为渲染算法服务的不可见字符。它们是 U+200E LEFT-TO-RIGHT MARK、U+200F RIGHT-TO-LEFT MARK、嵌入和控制符 U+202A–U+202E 以及隔离符 U+2066–U+2069。都不携带语义。保留它们的话最终会出现在 token 内部,这就是为什么从渲染后的网页复制出的词无法匹配输入到搜索框的同一个词。
零宽非连接符 U+200C 是不能盲目删除的例外。在波斯语中它分隔书写词内部的语素:现在进行时前缀和动词是一个正字法单元,由 ZWNJ 连接。删除它会将两个语素粘合成一个不是词的字符串;在那里插入空格则将它们拆分为两个 token——这正是 Lucene 的波斯语字符过滤器所做的,通常是更好的检索默认值。零宽连接符 U+200D 则不同,在 emoji 序列中承担结构功能,因此对混合内容全局去除是不安全的。
数字是容易的收益。阿拉伯-印度数字位于 U+0660–U+0669,扩展集(波斯语和乌尔都语)位于 U+06F0–U+06F9。用其中任一形式撰写的文档索引后不会产生任何 ASCII 查询能找到的数字 token。在索引中折叠为 ASCII 并对查询做同样处理,同时注意你已使得显示形式无法从 key 中恢复,因此保留原始字段用于展示。
规范化不是分词,也不是词干提取。在此管线之后,阿拉伯语仍然需要处理定冠词、连词和介词附着的词缀,希伯来语也需要自己的形态学处理。管线的职责是确保词干提取器看到每个词的一种拼写,而不是代替词干提取器完成其工作。
将其写为一个纯函数,接收字符串并返回字符串,调用时不读取配置。从一个模块导出。
按上述顺序排列各阶段:NFKC → 移除格式字符 → 去除变音符号 → 统一字母变体 → 折叠数字。每个阶段添加一个单元测试来断言中间值,这样重新排序会被捕获,而不是事后才被观察到并归咎于糟糕的召回率。
明确决定两个需要判断的处理并在注释中记录:teh marbuta 映射到 heh 还是不映射,以及波斯语字母折叠为阿拉伯语还是阿拉伯语折叠为波斯语。两者都依赖语料库,没有 universally 正确的答案。
在摄入时应用,位于分块之前,以便 chunk 边界基于规范化后的文本计算。在分块之后应用意味着你存储的偏移量指向一个不同的字符串。
在同一条请求路径上对查询应用相同的函数。如果两者位于不同服务中,则将此函数作为带版本号的共享库发布,而不是重新实现。
在单独字段中保留原始文本用于展示和高亮。规范化形式是 key,不是内容;向读者展示去除了 hamza 的阿拉伯语是可见的质量下降。
用 hexdump 验证,不要用眼睛。本管线中的每个差异在屏幕上都是不可见的——这是由设计保证的。
如果同一个规范化文本被多个 provider 嵌入——一个作为主 provider,一个作为 fallback——两个索引必须从字节完全相同的输入构建,而 mid-ingest 时触发的 fallback 是让这一保证不再成立的一种简单方式。Multigrid 那样的网关会记录每次调用的精确请求体,这通常是证明哪个形式实际上到达了那些行为异常的请求的 embedding model 的最快方法。