CJK 二元分词器如何为九种语言的搜索提供支持
基于空格的分词器无法正确处理韩文、中文和日文文本。本文介绍一种二元语法方法,可以用一个索引搜索九种语言。
本博客提供九种语言的文章,并且每种语言都需要可用的标题搜索功能。英文标题可以毫无问题地按空格分词。韩文、中文和日文的标题则不行,因为这些语言在书写时词语之间没有空格。一个搜索框必须能处理这两种情况。解决这个问题的办法是使用一个二元分词器 (bigram tokenizer):对于 CJK(中日韩)文本,它会索引重叠的双字符窗口,而对于拉丁文本,它会保留整个单词。本文将逐步介绍为什么按空格分词会失效、如何构建二元分词器,以及何时应该转而使用更重量级的形态分析器。
为什么按空格分词对中日韩文本无效
分词器将字符串转换为可供索引和匹配的单元。大多数数据库中的默认分词器按空白字符和标点符号进行分词。对于英语来说,这是一个合理的词语起止模型。标题 "cursor pagination in firestore" 会变成四个词元,搜索 "pagination" 就能找到它。
韩语、中文和日语的词语之间不加空格。像 "전세보증금월세" 这样的韩语短语被写成一串不间断的字符,尽管读者会将其解析为几个概念。空白字符分词器会将这整个字符串视为单个词元。唯一能匹配的查询是逐个字符完全相同的完整字符串。单独搜索 "월세" 不会得到任何结果,因为 "월세" 不是一个词元,而 "전세보증금월세" 才是。
因此,对英语有效的分词器,在处理中日韩文本时会产生一个巨大而无用的词元。你不能选择一种策略并将其应用于所有地方。分词器必须检查文本,并根据每个字符决定应用哪条规则。
二元分词:重叠的双字符窗口
二元分词器的思路是,不再尝试寻找 CJK 文本中的词语边界,而是为每对相邻的字符建立索引。以“전세보증금”为例,用一个双字符窗口划过它:
| 窗口位置 | 词元 |
|---|---|
| 1 到 2 | 전세 |
| 2 到 3 | 세보 |
| 3 到 4 | 보증 |
| 4 到 5 | 증금 |
五个字符产生四个二元分词。窗口重叠一个字符,这正是搜索能够奏效的原因。因为“보증”现在本身就是一个词元,所以对“보증”的查询能够匹配。对“전세”的查询也是如此,对完整的“전세보증금”也是如此,它会扩展为同样的四个二元分词,当所有这些分词都存在时即可匹配。
有一条绝不能违反的规则是,索引和查询必须使用完全相同的分词器。如果索引存储的是二元分词,但查询却通过了空白字符分词器,那么查询词元“전세보증금”将永远不等于任何已存储的二元分词,搜索将不会返回任何结果。这是二元分词搜索悄无声息地失效的最常见方式。文档在那里,词元也在那里,但每次查询都返回空结果,因为两端在如何切分字符串上存在分歧。将两条路径都通过同一个函数处理,这类错误就会消失。
拉丁文本不采用二元分词路径。将“firestore”分解为“fi”、“ir”、“re”等会使索引膨胀,并匹配到无意义的片段。对于已经用空格标记词语边界的文字系统,完整的单词才是合适的分词单元。
通过 Unicode 区块检测每个字符所属的文字
为了在单次处理中混合使用这两种策略,分词器会根据每个字符的 Unicode 区块对其进行分类。字符位于固定的数字范围内,而 CJK 文字则占据已知的区块。一个小函数会判断一个符文 (rune) 是否属于二元分词路径:
func isCJK(r rune) bool {
switch {
case r >= 0x4E00 && r <= 0x9FFF: // CJK Unified Ideographs
return true
case r >= 0x3400 && r <= 0x4DBF: // CJK Extension A
return true
case r >= 0xAC00 && r <= 0xD7A3: // Hangul syllables
return true
case r >= 0x3040 && r <= 0x309F: // Hiragana
return true
case r >= 0x30A0 && r <= 0x30FF: // Katakana
return true
}
return false
}
谚文 (Hangul) 涵盖韩语,表意文字区块涵盖中文和日语中的汉字,而假名区块则涵盖日语的其余部分。任何超出这些范围的内容,包括拉丁字母、数字、泰语、印地语和越南语,都采用单词路径。越南语使用带变音符号的拉丁文字书写,并用空格分隔单词,因此,尽管它在地理上是邻近 CJK 的语言,但在这里其行为与英语类似。
分词器逐个符文 (rune) 地遍历字符串,将拉丁字符收集到一个运行中的单词里,并将 CJK 字符收集到一个运行中的缓冲区里。当遇到边界、空白字符、标点符号或从一种文字切换到另一种文字时,它会清空 (flush) 已有的内容:
func Tokenize(s string) []string {
s = strings.ToLower(norm.NFKC.String(s))
var tokens []string
var latin, cjk []rune
flushLatin := func() {
if len(latin) > 0 {
tokens = append(tokens, string(latin))
latin = latin[:0]
}
}
flushCJK := func() {
tokens = append(tokens, bigrams(cjk)...)
cjk = cjk[:0]
}
for _, r := range s {
switch {
case isCJK(r):
flushLatin()
cjk = append(cjk, r)
case unicode.IsLetter(r) || unicode.IsDigit(r):
flushCJK()
latin = append(latin, r)
default:
flushLatin()
flushCJK()
}
}
flushLatin()
flushCJK()
return dedupe(tokens)
}
二元分词辅助程序处理滑动窗口,并为只有一个字符长的连续字符段提供回退方案:
func bigrams(rs []rune) []string {
if len(rs) == 0 {
return nil
}
if len(rs) == 1 {
return []string{string(rs)}
}
out := make([]string, 0, len(rs)-1)
for i := 0; i+1 < len(rs); i++ {
out = append(out, string(rs[i:i+2]))
}
return out
}
一个混合了多种文字的标题,例如“Firestore 색인”,会产生拉丁语词元 "firestore" 以及来自韩语部分的二元分词 "색인"。两者都会进入同一个词元列表,用任一语言搜索都能找到该帖子。
在分词前进行规范化
两个在读者看来完全相同的字符串,其字节可能完全不同。一个全角拉丁字母“A”与一个普通“A”是不同的码点。一些带重音的字符既可以作为单个组合码点存在,也可以作为基底字母后跟一个组合标记存在。如果索引存储的是一种形式,而查询传入的是另一种形式,那么即使人类看到的是相同的文本,它们也不会匹配。
分词器首先使用 NFKC 进行规范化,然后转换为小写。NFKC 是 Unicode 规范化形式的兼容性组合。它会折叠兼容性变体,因此全角字符会折叠为其普通等效字符,并且它会将基底字母加组合标记的序列组合成存在的单个码点。然后,转换为小写会消除拉丁文字因大小写而导致的不匹配。
import (
"strings"
"golang.org/x/text/unicode/norm"
)
func normalize(s string) string {
return strings.ToLower(norm.NFKC.String(s))
}
在分词器的顶层执行一次此操作,意味着每个下游步骤看到的都是干净的文本。由于索引和查询都调用相同的分词器,它们都可以免费获得相同的规范化处理,从而使双方保持一致。
存储词元并使用 $all 查询
每个帖子文档都带有一个 title_tokens 数组,其中包含其标题经分词器处理后的输出。在索引时,词元会计算一次,并与帖子一同写入:
doc := bson.M{
"slug": post.Slug,
"lang": post.Lang,
"title": post.Title,
"title_tokens": Tokenize(post.Title),
}
在 title_tokens 上建立的多键索引让数据库可以匹配数组中的任何元素。搜索时会将传入的查询通过相同的分词器处理,并请求其词元数组包含所有查询词元的文档:
func SearchFilter(query string) bson.M {
tokens := Tokenize(query)
if len(tokens) == 0 {
return bson.M{}
}
return bson.M{
"title_tokens": bson.M{"$all": tokens},
}
}
$all 操作符要求每个查询词元都必须存在于文档中,这就在词元之间实现了“与”逻辑。对于 CJK 查询,这正是让部分匹配感觉自然的原因。搜索“보증금”会变成二元组“보증”和“증금”,任何包含该子字符串的标题在其词元数组中都会有这两个二元组,因此会匹配。一个只包含“보증”而不包含“증금”的标题不会匹配,这正是你想要的行为,因为用户查询的是更长的词组。
有一个边缘情况需要了解。因为 $all 检查的是存在性,而不是邻近性,所以二元组的重叠可能会导致匹配到在原文中顺序错乱的词元序列。在实践中,对于短标题搜索,这种情况很少见且无害。如果你需要严格的邻近性,就需要存储位置信息并进行检查,但这会带来更多的索引和查询成本,对于标题搜索来说得不偿失。
权衡:索引大小与单字符查询
二元分词并非没有代价。第一个代价是索引大小。一个包含 n 个 CJK 字符的标题会产生 n-1 个词元,并且每个词元都会进入多键索引。与可能为同一标题生成一两个词元的空白分词器相比,二元分词索引要大好几倍。对于几千篇文章的标题搜索来说,这只是一个很小的绝对数字。但对于数百万文档的全文搜索,这将是主要的成本,在采用二元分词之前,你需要三思。
第二个代价是单字符查询。单个 CJK 字符无法构成二元分词,因此分词器会回退到将其作为一个单独的词元发出。这只有在单个字符也被索引的情况下才有效,而在这里,通过 bigrams 辅助程序中相同的单字符回退机制,确实做到了这一点。但单个字符是一个弱查询。它会匹配很大一部分文档,并且排名不佳,因此即使技术上返回了行,结果也几乎毫无用处。二元分词搜索是为两个或更多字符的查询而构建的,而短查询是其最薄弱的地方。
这两个代价都不是在标题搜索中避免使用二元分词的理由。它们是让你了解该技术在何种边界内保持低成本的理由。
何时形态分析器是更好的选择
二元分词的替代方案是形态分析器,这是一种能真正理解语言并将文本拆分为真实单词的分词器。对于韩语,这意味着工具知道“전세보증금”是一个复合词,并且可以像读者一样将其各部分分开。对于日语,这意味着在没有空格引导的情况下,识别一个词的结束和下一个词的开始。
形态分析器会产生更干净、更有意义的词元,以及更小的索引,因为它生成的是真实单词,而不是每个重叠的词对。其成本也是实实在在的。每种语言都需要自己的分析器和自己的词典,词典很大,并且需要随着语言使用的变化而更新,而且,对每个文档的分析比滑动窗口更耗费资源。以这种方式支持九种语言意味着需要集成和维护几个独立的分析器,每个分析器都有其自身的特性和故障模式。
下表从一个角度展示了这种权衡:
| 属性 | 二元分词 | 形态分析器 |
|---|---|---|
| 语言支持 | 一条规则适用于所有 CJK 语言 | 每种语言一个分析器 |
| 词元质量 | 重叠词对 | 真实单词 |
| 索引大小 | 较大 | 较小 |
| 依赖项 | 除 Unicode 数据外无依赖 | 各语言的词典 |
| 短查询 | 较弱 | 更好 |
对于本博客而言,选择是明确的。标题搜索是一个小而容错的场景。用户输入几个字符并期望匹配到文章,他们并不是在进行语言学分析。二元分词通过一条语言中立的规则、无需各语言的词典、无需同时为九种语言维护最新的分析器,即可实现这一点。如果搜索场景扩展为对文章正文进行排序的全文搜索,此时词元质量和索引大小比简单性更重要,那么为 CJK 语言使用形态分析器将物有所值。应根据问题的规模来匹配分词器,而对于标题搜索,二元分词的规模正合适。