前陣子幫中文文章加 SQLite 全文搜尋,原本以為 FTS5 是 SQLite 內建功能,事情應該很單純:建表、塞資料、下 MATCH,收工。結果拿一段再普通不過的中文測試,第一關就卡住了。先用預設 tokenizer 建表:
CREATE VIRTUAL TABLE posts USING fts5(content);
INSERT INTO posts(content) VALUES ('手沖咖啡的關鍵在於水溫與注水速度');
SELECT * FROM posts WHERE posts MATCH '咖啡';
結果是空的。資料明明有「咖啡」,MATCH 卻像沒看見一樣。這種時候最容易直覺懷疑索引漏建,但問題其實不在這裡。
查了一圈,常見解法是改用 trigram tokenizer。三個字以上的中文片段確實立刻查得到,乍看很像解套;不過我多補了一個兩字詞測試,才發現這招也有邊界:
CREATE VIRTUAL TABLE posts2 USING fts5(content, tokenize='trigram');
INSERT INTO posts2(content) VALUES ('手沖咖啡的關鍵在於水溫與注水速度');
SELECT * FROM posts2 WHERE posts2 MATCH '咖啡';
一樣是空的。這次不是分詞失敗,而是「咖啡」只有兩個字,連一個 trigram token 都湊不出來。
再往下追,才發現真正要一起看的有兩件事:FTS5 的 bm25() 究竟統計什麼,以及 trigram tokenizer 實際切出了哪些 token。各自看都不複雜,麻煩的是兩者綁得很緊:tokenizer 定義的「詞」,正是 bm25() 用來判斷相關性的原料。原料換了,公式雖然沒動,排名的意義卻可能已經變了。
FTS5 分兩件事在做:切詞,和算分
CREATE VIRTUAL TABLE ... USING fts5(...) 建出來的是一張虛擬表(virtual table)。寫入資料時,FTS5 先交給tokenizer(分詞器)切成 token,再建立反向索引(inverted index),記錄 token 出現在哪些列、哪些位置。查詢時,查詢字串也會走同一把 tokenizer,然後用切出的 token 回頭查索引。
排序是另一件事。你可以完全不排序,也可以呼叫一個輔助函式(auxiliary function),FTS5 內建的只有 bm25()。它會利用同一份 token 統計出的詞頻、文件頻率,換算出一個分數。
兩件事可分開替換:FTS5 內建的 ranking function 實際上只有 bm25(),tokenizer 卻有 unicode61、ascii、porter、trigram,也能自行註冊。換 tokenizer 不會改寫 bm25() 的公式,卻會整批換掉它眼中的「詞彙表」。後面看到的怪排序,就是從這裡長出來的。
BM25 怎麼把「出現次數」換算成分數
BM25(Best Matching 25)的想法其實很務實:一個詞若很少文件提到,鑑別度就高;同一篇裡提得越多次,通常也越相關。但它不會傻傻地把重複次數一路相加,文件夠長本來就容易多出幾次命中,所以還會做飽和與長度正規化。
把直覺攤開,公式的三個部分各有分工:
score(D, Q) = Σ IDF(qi) × [ f(qi, D) × (k1 + 1) ]
─────────────────────────────────────
f(qi, D) + k1 × (1 − b + b × |D| / avgdl)
IDF(qi) = ln( (N − n(qi) + 0.5) / (n(qi) + 0.5) + 1 )
N是文件總數,n(qi)是包含詞qi的文件數。IDF 只看「多少篇文件出現過這個詞」,不管出現幾次。f(qi, D)是詞qi在文件 D 裡的次數,k1控制它飽和的速度:k1越大,重複出現的邊際貢獻掉得越慢;k1 = 0等於乾脆不看次數,只看有沒有出現過。|D|是這篇文件的長度,avgdl是全體平均長度,b控制長度正規化的力道:b = 0完全不管長度,b = 1完全按相對長度打折。
(IDF 尾端的 +1 不是裝飾。少了它,若一個詞出現在超過半數文件,IDF 可能變成負值,命中反而像被扣分。)
實際跑一次,三個因素怎麼互相拉扯就很清楚。以下先手動用空白標出詞界(這個前置動作後面會變成關鍵):
CREATE VIRTUAL TABLE seg USING fts5(content);
INSERT INTO seg(content) VALUES
('手沖 咖啡 的 關鍵 在於 水溫 與 注水 速度'),
-- 「咖啡」出現 1 次,共 9 個詞
('義式 咖啡 機 需要 細 研磨 的 咖啡 粉 咖啡 油脂 比 手沖 咖啡 更 明顯'),
-- 「咖啡」出現 4 次,共 16 個詞
('貓咪 喜歡 在 早上 曬 太陽');
-- 完全沒提到咖啡
SELECT rowid, bm25(seg) AS score, content
FROM seg WHERE seg MATCH '咖啡'
ORDER BY score;
2 -1.5456e-06 義式 咖啡 機 需要 細 研磨 的 咖啡 粉 咖啡 油脂 比 手沖 咖啡 更 明顯
1 -1.0557e-06 手沖 咖啡 的 關鍵 在於 水溫 與 注水 速度
不提咖啡的第三篇,根本不在結果裡。MATCH 先把它篩掉,bm25() 從來沒機會替它打分。
FTS5 的分數越負越相關,原因下一節再說。先看這組數字:第 2 篇「咖啡」有 4 次,是第 1 篇的 4 倍,但篇幅也幾乎多一倍。一邊是次數增加、分數往下;另一邊是文件變長、效果被打折,最後只差約 1.46 倍。這正是 BM25 想回答的問題:不是單純數出現幾次,而是問「放在這篇的長度裡,這個次數到底算不算多」。
FTS5 的 bm25():你真正會呼叫的那個函式
SELECT rowid, bm25(seg) AS score -- 不給權重,每欄預設權重都是 1.0
FROM seg WHERE seg MATCH '咖啡'
ORDER BY score; -- 分數越負排越前面
SELECT rowid, bm25(seg, 10.0, 1.0) AS score -- 假設有兩欄,第一欄權重放大成 10 倍
FROM seg WHERE seg MATCH '咖啡'
ORDER BY score;
bm25(table, weight0, weight1, ...) 的第一個參數是表名,後面依欄位給權重;沒給就都是 1.0。這裡有個很容易看反的小細節:FTS5 回傳時刻意乘上 -1,所以越負代表原始 BM25 越高,才能直接寫遞增 ORDER BY。若不直接呼叫 bm25(),而是 ORDER BY rank,效果等同使用未加欄位權重的 bm25()。
公式裡的 k1 和 b,FTS5 沒開放調整,實作固定為 k1 = 1.2、b = 0.75。這正好是資訊檢索教材常見的預設組合;Okapi BM25 自 1994 年提出後,這組設定在一般語料的表現相當穩定,SQLite 就直接沿用。(SQLite FTS5 文件)
到這裡,bm25() 本身沒做錯事。它只隱含一個前提:餵進來的 token,大致得對應到人類理解的「詞」。中文遇上不會中文分詞的 tokenizer,這個前提就開始鬆動。
中文沒有空白,unicode61 憑什麼斷詞
開頭查不到「咖啡」的例子,用的是 FTS5 未指定 tokenize 參數時的預設值 unicode61。它只看 Unicode 字元分類:空白、標點是分隔,其餘都是「詞內字元」,中文字也不例外。英文有空白幫忙斷詞;中文沒有,一段沒有標點的句子在 unicode61 看來就是一個超長 token:
CREATE VIRTUAL TABLE d1 USING fts5(content, tokenize='unicode61');
INSERT INTO d1(content) VALUES ('手沖咖啡的關鍵在於水溫與注水速度');
CREATE VIRTUAL TABLE d1_vocab USING fts5vocab(d1, 'row');
SELECT term FROM d1_vocab;
手沖咖啡的關鍵在於水溫與注水速度
整句被當成一個 token。MATCH '咖啡' 要找的是完整 token「咖啡」,索引裡卻只有整串句子,結果自然是零筆。
加標點會不會好一點?
CREATE VIRTUAL TABLE d2 USING fts5(content, tokenize='unicode61');
INSERT INTO d2(content) VALUES ('手沖咖啡,關鍵在於水溫、注水速度。');
CREATE VIRTUAL TABLE d2_vocab USING fts5vocab(d2, 'row');
SELECT term FROM d2_vocab;
手沖咖啡
關鍵在於水溫
注水速度
有比沒有好,但問題還在。它切的是「標點之間的一段」,不是中文詞;索引中有「手沖咖啡」,沒有獨立的「咖啡」。而 FTS5 的 MATCH 預設要求整個 token 相符,並不是子字串比對。
unicode61並不是中文斷詞器做得不夠好;它的設計本來就不理解「中文詞」。它只區分分隔字元與非分隔字元。
trigram tokenizer:乾脆不管詞,只看連續三個字
trigram tokenizer 是 SQLite 3.34.0(2020 年 12 月)加入 FTS5 的功能。(Release notes)它索性不判斷語言或詞界,而是拿連續三個字元做滑動窗口,切成一串彼此重疊的 token:
CREATE VIRTUAL TABLE tri USING fts5(content, tokenize="trigram");
-- 或帶選項:
CREATE VIRTUAL TABLE tri USING fts5(content, tokenize="trigram case_sensitive 1");
拿開頭那句話實測:
CREATE VIRTUAL TABLE d3 USING fts5(content, tokenize='trigram');
INSERT INTO d3(content) VALUES ('手沖咖啡的關鍵在於水溫與注水速度');
CREATE VIRTUAL TABLE d3_vocab USING fts5vocab(d3, 'row');
SELECT term FROM d3_vocab;
咖啡的 啡的關 在於水 手沖咖 於水溫 水溫與
水速度 沖咖啡 注水速 溫與注 的關鍵 與注水
鍵在於 關鍵在
(fts5vocab 不保證回傳順序,這裡照實列出,不是切分順序。16 個字元、14 個 trigram,剛好對得上「字數 − 2」。)
沒有語言知識、沒有字典,只有字元滑動窗口。這正是它跨語言好用的原因,也正是限制來源:窗口固定三個字元,查詢字串少於三個字元,根本組不出 token。
SELECT count(*) FROM d3 WHERE d3 MATCH '咖啡'; -- 0
FTS5 文件把規則寫得很清楚:少於三個 Unicode 字元的子字串,用全文查詢一律查不到,資料裡有也沒用。(SQLite FTS5 文件)「咖啡」、「日文」、「北京」這類兩字詞,全都落在這個限制內。
那兩字詞怎麼辦?trigram 的另一項實用能力,是讓同一張表的 LIKE/GLOB 也能利用索引,不必每次全表掃描:
SELECT count(*) FROM d3 WHERE content LIKE '%咖啡%'; -- 1,而且吃得到索引
case_sensitive 和 remove_diacritics 是唯二的兩個選項,預設都是 0(關閉),而且不能同時打開。一旦 case_sensitive = 1,能走索引的只剩 GLOB,LIKE 不再受惠於索引。(SQLite FTS5 文件)
再補一個容易被忽略的背景:SQLite 官方文件沒有把 trigram 說成中文或 CJK 的正式解法。它的設計動機是加速任何語言的子字串查詢,文件範例也用英文 cot 尋找「Scott」、「cottage」。拿它處理中文是實務上常見的延伸用法,不代表它會自動具備中文詞意;這點正是下一節的核心。
同一個詞,會被拆成好幾種不同的 token
trigram 能讓我用夠長的片段查到「咖啡」,例如「沖咖啡」,但它從來不知道「咖啡」是一個詞。相同詞彙落在不同前後文,索引中就會變成不同 token:
CREATE VIRTUAL TABLE raw USING fts5(content, tokenize='trigram');
INSERT INTO raw(content) VALUES
('手沖咖啡的關鍵在於水溫與注水速度'),
-- 「咖啡」出現 1 次
('義式咖啡機需要細研磨的咖啡粉咖啡油脂比手沖咖啡更明顯');
-- 「咖啡」出現 4 次
CREATE VIRTUAL TABLE raw_vocab USING fts5vocab(raw, 'row');
SELECT term, doc, cnt FROM raw_vocab WHERE term LIKE '%咖啡%' ORDER BY term;
咖啡更 1 1
咖啡機 1 1
咖啡油 1 1
咖啡的 1 1
咖啡粉 1 1
式咖啡 1 1
沖咖啡 2 2
的咖啡 1 1
粉咖啡 1 1
兩篇文件裡「咖啡」實際出現 5 次,索引卻長出 9 種 token;兩篇共用的只有「沖咖啡」,其他 8 種都只落在其中一篇。BM25 的 IDF 原本要看「一個詞出現在幾篇文件」,tf 要看「它在這篇出現幾次」,但現在兩者數的都不是「咖啡」,而是某個指定的三字組合。
真實出現次數可以直接算出來對照:
SELECT rowid,
(length(content) - length(replace(content, '咖啡', ''))) / length('咖啡')
AS 真實出現次數
FROM raw;
1 1 -- 手沖咖啡的關鍵…
2 4 -- 義式咖啡機…
拿兩篇剛好共用的 token 查一次:
CREATE VIRTUAL TABLE raw_inst USING fts5vocab(raw, 'instance');
SELECT doc, term, count(*) AS 這次查詢統計到的次數
FROM raw_inst
WHERE term = '沖咖啡'
GROUP BY doc, term;
1 沖咖啡 1
2 沖咖啡 1
第 2 篇明明有 4 次「咖啡」,MATCH '沖咖啡' 卻只算到 1 次。其餘 3 次分散在「咖啡機」、「咖啡粉」、「咖啡油」,跟這次查詢的 token 完全不同,索引不會把它們算進來。tf 不只是稍微偏低,而是直接漏掉四分之三。
漏掉的 tf 怎麼反映到分數上:
SELECT rowid, bm25(raw) AS score, content
FROM raw WHERE raw MATCH '沖咖啡'
ORDER BY score;
1 -1.1206e-06 手沖咖啡的關鍵在於水溫與注水速度
2 -0.9028e-06 義式咖啡機需要細研磨的咖啡粉咖啡油脂比手沖咖啡更明顯
結果第 1 篇分數更負、排在前面,BM25 認定它更相關;但第 1 篇只提一次咖啡,第 2 篇談的就是咖啡沖煮,出現 4 次。排名看似反直覺,其實公式沒有壞:它收到的 tf 兩篇都是 1,這時只剩長度正規化在發揮作用。第 2 篇較長,b 就把它往較不相關的方向推。
bm25()接到被 trigram 拆碎的統計,仍會忠實算完並回傳正常的浮點數。問題不在公式,而在它收到的已不再是「這個詞出現幾次」。
該怎麼選
回頭看一開始的兩個失敗案例,選擇其實不複雜,只是要先把搜尋目標講清楚:
- 要的是「最相關文章排前面」的中文全文搜尋,我會先做真正的中文分詞(jieba、CKIP、ICU word breaker 皆可),在詞界插入空白後再用
unicode61建索引。這樣bm25()才是在統計真正的詞。 - 要的是任意子字串、打字自動完成或部分符合,trigram 才是適合的工具;查詢走
LIKE/GLOB,不要把bm25()的排序當成語意相關性。也別忘了:兩字詞與單一中文字用MATCH查不到。
| 情境 | 建議 tokenizer | 該用的查詢方式 |
|---|---|---|
| 中文全文搜尋,排序要準 | 先分詞(jieba/CKIP/ICU)+ unicode61 | MATCH + bm25() |
| 允許任意子字串、打字自動完成 | trigram | LIKE / GLOB(吃得到索引) |
| 英文或其他有空白分隔的語言 | unicode61(預設)或 porter | MATCH + bm25() |
| 兩種需求都要 | 兩張表並存,各司其職 | 依查詢類型分流 |
trigram 索引也不是白拿。SQLite 官方以原始 1636 MiB 的郵件資料測試:預設 detail=full(保留完整位置資訊)索引是 743 MiB,detail=column 是 340 MiB,detail=none(只記錄文件是否出現,不留位置與次數)只剩 134 MiB。但空間是用功能交換的:detail=column/detail=none 會讓 phrase、NEAR 查詢失效;和 trigram 合用時,子字串還會限制為 3 個字元內,不能再靠多個 trigram 拼出較長片段。(SQLite FTS5 文件)
第三方 benchmark 更容易感受到量級:1820 萬列、1.3 GiB 的資料庫建完整 trigram 索引後到 3.7 GiB,改 detail=none 為 2.8 GiB;未建索引的子字串查詢約 1.75 秒,完整索引降到 10~30 毫秒,detail=none 約 14 毫秒。空間省了將近一半,速度幾乎沒犧牲。這是個人 benchmark,不是官方保證,資料分佈不同結果也會變;不過拿來估預算很有參考價值。
結語
BM25 不認得「詞」的語意,它只認得 tokenizer 餵進來的 token,接著統計這些 token 在多少文件出現、在單篇出現幾次、文件有多長。公式自 1994 年 Okapi 系統提出後沒太大改變,SQLite 也沿用預設 k1、b;真正決定這套計算能不能代表相關性的,是 tokenizer 對「詞」的定義。unicode61 看到的是空白或標點之間的片段;trigram 看到的是任意連續三個字元,和語意沒有必然關係。同一套公式、同一組常數,原料其實是兩種東西。
所以,下次 SQLite 的 bm25() 排出怪順序,我會先不急著怪演算法。先打開 fts5vocab 看一眼:tokenizer 到底把文字切成什麼樣子?這一步通常比反覆調排序更快找到問題,也能避免把不適合的工具硬套到另一種搜尋需求上。