資料庫

CJK 雙字元斷詞器如何驅動九種語言的搜尋

以空格為基礎的分詞器無法處理韓文、中文和日文文本。這是一種 bigram 方法,可使用單一索引搜尋九種語言。

本文由 AI 模型從英文原文翻譯而來,用字可能與原文有所出入。 閱讀英文原文

本部落格提供九種語言的文章,且每篇文章都需要可用的標題搜尋功能。英文標題可以毫無問題地以空格斷詞。韓文、中文和日文標題則不行,因為這些語言在書寫時詞語之間沒有空格。一個搜尋框必須同時處理這兩種情況。讓它運作的解決方案是一個 bigram 斷詞器:對於 CJK 文本,它會索引重疊的雙字元視窗;而對於拉丁文本,它會保留完整的單字。這篇文章將逐步說明為何以空格斷詞會失效、bigram 斷詞器是如何建構的,以及何時你會需要改用更重量級的形態學分析器。

為何以空格分詞對中日韓文字無效

分詞器會將字串轉換成您用來索引和比對的單位。大多數資料庫中的預設分詞器會根據空格和標點符號進行分詞。對於英文來說,這是一個合理的模型,用以判斷單字的開始和結束。標題 "cursor pagination in firestore" 會變成四個詞元,而搜尋 "pagination" 就能找到它。

韓文、中文和日文在單字之間不加空格。像 "전세보증금월세" 這樣的韓文片語會寫成一串連續的字元,即使讀者會將其解析為數個概念。以空格分詞的分詞器會將整串字元視為單一詞元。唯一能匹配的查詢是完全相同的完整字串,字元不差。單獨搜尋 "월세" 不會有任何結果,因為 "월세" 不是一個詞元,而 "전세보증금월세" 才是。

因此,對英文有效的分詞器,對中日韓文字卻只會產生一個巨大而無用的詞元。您不能選擇一種策略並將其應用於所有地方。分詞器必須檢視文本,並根據每個字元決定適用哪條規則。

二元分詞:重疊的雙字元視窗

二元分詞器的概念是,停止嘗試在中日韓文本中尋找詞語邊界,而是對每對相鄰的字元進行索引。以「전세보증금」為例,將一個雙字元視窗滑過它:

視窗位置 詞元
1 到 2 전세
2 到 3 세보
3 到 4 보증
4 到 5 증금

五個字元產生四個二元分詞。視窗重疊一個字元,這正是讓搜尋得以運作的部分。因為「보증」現在本身就是一個詞元,所以查詢「보증」時會匹配。查詢「전세」也是如此,完整的「전세보증금」亦然,它會擴展成相同的四個二元分詞,並在所有詞元都存在時匹配。

你絕對不能打破的一條規則是,索引和查詢必須使用完全相同的分詞器。如果索引儲存的是二元分詞,但查詢卻通過空白分詞器處理,那麼查詢詞元「전세보증금」將永遠不會等於任何已儲存的二元分詞,搜尋也將一無所獲。這是二元分詞搜尋最常見的無聲失敗方式。文件在那裡,詞元也在那裡,但每次查詢都返回空的結果,因為兩端對於如何切分字串的看法不一致。將兩個路徑都導向同一個函式,這類錯誤就會消失。

拉丁文本不走二元分詞的路徑。將「firestore」拆解成「fi」、「ir」、「re」等會使索引膨脹,並匹配到無意義的片段。對於已經用空格標示詞語邊界的書寫系統來說,完整的單字才是正確的單位。

使用 Unicode 區塊偵測每個字元的語系

為了在單次處理中混合這兩種策略,分詞器會根據每個字元的 Unicode 區塊進行分類。字元位於固定的數字範圍內,而 CJK 語系則佔用已知的區塊。一個小函式會決定一個 rune 是否屬於 bigram 路徑:

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 字元收集到一個運行中的緩衝區。當遇到邊界、空白、標點符號或從一種語系切換到另一種語系時,它會清空已有的內容:

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)
}

bigram 輔助函式處理滑動視窗,並為只有一個字元長的運行提供備用方案:

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」以及來自韓文部分的 bigram「색인」。兩者都會進入相同的權杖列表,用任一語言搜尋都能找到該篇文章。

在分詞前進行正規化

兩個對讀者來說看起來相同的字串,其逐位元組的內容可能不同。一個全形拉丁字母「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 運算子要求每個查詢權杖都必須存在於文件中,這在權杖之間形成了一個 AND 關係。對於中日韓(CJK)查詢而言,這就是讓部分比對感覺自然的原因。搜尋「보증금」會變成 bigram「보증」和「증금」,而任何包含該子字串的標題,其權杖陣列中都會有這兩個 bigram,因此會比對成功。一個只包含「보증」但不包含「증금」的標題則不會比對成功,這正是你想要的行為,因為使用者查詢的是更長的字串。

有一個邊緣案例需要注意。Bigram 的重疊可能會導致比對到一個在原始文本中順序錯亂的權杖序列,因為 $all 檢查的是存在性,而非相鄰性。在實務上,對於簡短的標題搜尋來說,這種情況很少見且無害。如果你需要嚴格的相鄰性,你就必須儲存位置並進行檢查,這會帶來更多的索引和查詢成本,對於標題搜尋來說並不划算。

權衡取捨:索引大小與單字元查詢

Bigram 並非沒有代價。第一個代價是索引大小。一個包含 n 個字元的 CJK 標題會產生 n-1 個 token,而每一個 token 都會被放入多鍵索引 (multikey index) 中。相較於空白分詞器 (whitespace tokenizer) 可能只會為同一個標題產生一或兩個 token,bigram 索引的大小會是好幾倍。對於橫跨數千篇文章的標題搜尋來說,這是一個很小的絕對數字。但對於橫跨數百萬份文件的全文內容搜尋,這將會是主要的成本,而你在採用 bigram 之前會需要更審慎地思考。

第二個代價是單字元查詢。單一的 CJK 字元無法形成 bigram,所以分詞器會退而求其次,將其作為單獨的 token 輸出。這只有在單一字元也被索引的情況下才有效,而在此處,透過 bigrams 輔助函式中相同的單字元備用機制,確實有做到這點。但單一字元是一個很弱的查詢。它會匹配到很大一部分的文件,且排名不佳,所以即使技術上回傳了資料列,結果也幾乎沒有用處。Bigram 搜尋是為兩個或更多字元的查詢所設計的,而簡短的查詢正是其最弱之處。

這兩項代價都不是在標題搜尋中避免使用 bigram 的理由。它們是讓你了解這項技術在何種邊界內能維持低成本的理由。

何時該選擇形態分析器

雙詞組合的替代方案是形態分析器,這是一種真正理解語言並將文本分割成實際詞彙的斷詞器。對於韓文來說,這意味著工具知道「전세보증금」是一個複合詞,並且能夠將其拆分成讀者會理解的部分。對於日文來說,這意味著在沒有空格引導的情況下,能夠辨識出一個詞的結束和下一個詞的開始。

形態分析器會產生更乾淨、更有意義的詞元,以及更小的索引,因為它產生的是實際的詞彙,而不是每個重疊的詞對。成本也是實實在在的。每種語言都需要自己的分析器和字典,這些字典很大,且需要隨著語言使用的變化而更新,而且對每個文件的分析比滑動視窗更耗費資源。用這種方式支援九種語言,意味著要整合和維護數個獨立的分析器,每個都有其自身的特性和故障模式。

下表從一個角度呈現了其中的權衡:

特性 雙詞組合 形態分析器
語言支援 一條規則適用所有 CJK 每種語言一個分析器
詞元品質 重疊詞對 實際詞彙
索引大小 較大 較小
依賴項 除了 Unicode 資料外無 各語言的字典
簡短查詢 較弱 較佳

對於這個部落格來說,選擇是明確的。標題搜尋是一個小而容錯率高的應用場景。使用者輸入幾個字元,期望找到匹配的文章,他們並不是在進行語言學分析。雙詞組合用一條語言中立的規則就達成了這個目標,不需要各語言的字典,也不需要同時為九種語言維護最新的分析器。如果搜尋範圍擴大到對文章內文進行排序的全文搜尋,屆時詞元品質和索引大小的重要性將超過簡單性,那麼為 CJK 語言使用形態分析器就物有所值了。讓斷詞器與問題的規模相匹配,而對於標題搜尋來說,雙詞組合就是恰當的規模。