一個線上全文索引BUG的排查:關於類阿拉件數字的分詞與檢索

是奉壹呀發表於2023-05-18

說到全文檢索的分詞,多半講到的是中(日韓)文分詞,少有英文等拉丁文系語言,因為英語單詞天然就是分詞的。
但更少講到阿拉伯數字。比如金額,手機號碼,座機號碼等等。

以下不是傳統的從0開始針對mysql全文索引前世今生講起。
我更喜歡從一個小問題入手,見縫插針的將相關的知識點,以非時間線性順序零散穿插起來。

從一個線上的BUG說起

我們有一張人口表,裡面的資料有多種資料來源合併而來,因此每個使用者的手機號可能有多個。
這也很好理解,有的人就是有多個手機號,有的人就是經常換手機號,對吧。
現在有個功能需要透過手機號去關聯使用者。

因為手機號有多個,所以要麼使用like進行模糊匹配。使用者表有上千萬條記錄,這樣的效率肯定是不能接受的。

select * from t_user where phone like '%13112345678%'

要麼使用另一個折中的方案,將手機號單獨成表,使用者表對手機號表一對多關聯。
這種方式效率上能接受,但需要改變現有資料結構,故放棄。

select u.id,u.username,u.phone from t_user u LEFT JOIN t_user_phone p on u.id = p.user_id where p.phone = '13112345678'

最終選用全文索引。(mysql 5.7.6+)

先在使用者錶針對手機號建立一個全文索引。
使用內建分詞引擎ngram

CREATE FULLTEXT INDEX idx_full_text_phone ON t_user (phone) WITH PARSER ngram;

當使用手機模糊查詢關聯使用者時可使用以下語句。

  1. 布林模式模糊檢索
select * from t_user where match(phone) AGAINST('13996459860' in boolean mode)
  1. 自然語言模式。mysql預設為此模式,所以第2條sql沒有顯式指定時,仍然為自然語言模式。
select * from t_user where match(phone) AGAINST('13996459860' in NATURAL LANGUAGE mode)
或
select * from t_user where match(phone) AGAINST('13996459860')

根據我們的需求,查詢手機號需要全匹配才算命中。所以選擇布林模式。
自然語言模式做不到。
關於布林模式和自然語言模式的區別,後面做介紹。


以上算是簡單的背景介紹。

但是
萬惡的但是,雖遲但到

有一天產品過來告訴我,某個手機號關聯出來上百個人。
他問,這種情況是正常的嗎?

他如果直接說你這裡有個bug,我可能直接就懟回去了(bushi ?
但是他說得這麼委婉,我反而沒底了。 ?


不要對一個程式設計師說:你的程式碼有Bug。他的第一反應是:①你的環境有問題吧;②S13你會用嗎?
如果你委婉地說:你這個程式和預期的有點不一致,你看看是不是我的使用方法有問題?
他本能地會想:woco!是不是出Bug了!

直覺告訴我這不正常,不然這個人是搞電詐或者海王嗎?

我拿手機號去資料庫裡查詢。使用布林模式全文檢索,確實關聯出來多個人。
但也確實是個BUG.

我們來完整地模擬一下。
先建立一張測試使用者表。
phone欄位加上全文索引,使用ngram分詞器。

CREATE TABLE `t_user` (
  `id` int(11) NOT NULL,
  `username` varchar(10) COLLATE utf8_bin DEFAULT NULL,
  `phone` varchar(50) COLLATE utf8_bin DEFAULT NULL,
  PRIMARY KEY (`id`),
  FULLTEXT KEY `idx_full_text_phone` (`phone`) /*!50100 WITH PARSER `ngram` */ 
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

插入幾條測試資料

-- ----------------------------
-- Records of t_user
-- ----------------------------
INSERT INTO `t_user` VALUES ('1', '張三', '13996459860,15987569874,0797-12345');
INSERT INTO `t_user` VALUES ('2', '李四', '0797-6789');
INSERT INTO `t_user` VALUES ('3', '王五', '0797-94649');

正常情況下

select * from t_user where match(phone) AGAINST('13996459860' in boolean mode)
select * from t_user where match(phone) AGAINST('13996459860' in NATURAL LANGUAGE mode)
select * from t_user where match(phone) AGAINST('13996459860')

都能得到

異常情況

select * from t_user where match(phone) AGAINST('0797-12345' in boolean mode)

得到結果

可以看到後面兩條記錄不是預期的結果。
也是產品經理反映的問題。

大家應該都猜到了,就是座機號的原因。嗯,使用者有個座機,這很河狸嘛。

都是廣義上的聯絡方式嘛。

看起來,這條SQL是將包含0797的資料行都返回了,但我使用的是布林模式,要求全部匹配上0797-12345才返回。

我猜可能是'-'導致分詞的問題,將其分成了兩部份。


分詞器


分詞就是對需要進行搜尋的關鍵詞進行拆分。MySQL最初支援全文索引時,使用的是parser (拉丁語法分詞器,透過空格來分詞),
如英文I am programmer ,天然可以透過空格拆分成I am programmer3個單詞,這也就是前文說的英語天然沒有分詞的問題。

但對於像中文這類不以空格拆分詞語的語言來說無法適用。
因此MYSQL5.7.6後提供了n_gram parser字元長度分詞器) ,對中文的全文索引支援更友好,分詞器的使用也很簡單,建立索引時新增 WITH PARSER ngram即為使用n_gram parser(字元長度分詞器),不加則預設使用傳統parser(拉丁語法空格分詞器)。

注意字元長度分詞器這幾個字,故名思義,它就是按字元的長度來分詞的,之所以單獨提出來,是區別於基於NLP自然語義的分詞,如復旦分詞等。

比如我是程式設計師這個短句,如果按照自然語義分析來進行分詞的話,它可能會分成 程式 程式設計師等。
斷不可能分出來序員。除非分詞器有問題。

n_gram parser分詞器就有可能。 mysql預設分詞長度為2,可在my.cnf裡進行配置,ngram_token_size = 2指定分詞長度。

針對不同的分詞長度,我是程式設計師這個短句可以有以下多種分詞效果。


ngram_token_size=1: '我', '是', '程', '序', '員'
ngram_token_size=2: '我是', '是程', '程式' , '序員' 
ngram_token_size=3: '我是程', '是程式' , '程式設計師'
...
ngram_token_size=5: '我是程式設計師'
...
最大ngram_token_size=10

我的測試庫ngram_token_size為2,加個欄位簡單測試一下。

單個字搜不到,因為最小分詞單位為2。

搜尋程式序員都能得到正確的結果。




以上是漢字的分詞,回到今天的正題,對於阿拉伯數字呢?
如金額23.45元,手機號13912345678,座機號0797-12345678,日期2023-01-01等等。

針對上面說到的BUG,座機號0797-12345678關聯出來了多個帶0797但-後面不相同的號碼,
我一開始以為是-的問題。它將0797-12345678分成了079712345678兩部份。

但透過這一小節的n_gram parser的介紹,我們知道它是基於長度的分詞器,那麼原因肯定就不是這樣的。

透過以下兩句SQL可以證明它是兩兩拆分的。

select * from t_user where match(phone) AGAINST('7-' in NATURAL LANGUAGE mode)
select * from t_user where match(phone) AGAINST('07' in boolean mode)

7-07都能將3條記錄全部匹配出來。

但是在布林模式下,7-搜尋不出來。

為什麼呢?

這裡mysql把7-中的-當成邏輯運算子了,而不是整體當作一個搜尋關鍵詞。


stopword


內建的MySQL全文解析器將單詞與stopword 列表中的條目進行比較。如果一個單詞在stopword列表當中,則該單詞將從索引中排除。

對於ngram解析器,stopword處理的執行方式不同。ngram解析器不排除與stopword中的條目相等的令牌,而是排除包含stopword的令牌。

例如,假設ngram_token_size=2,包含a,b的文件將被解析為a,,b
如果逗號被定義為stopword,則a,,b都將從索引中排除,因為它們包含逗號。


同理,如果stopword當中包含-,同時ngram_token_size=4,那麼座機號0797-1789就被拆分成兩個大的部份,07971789
其中 797-1 97-17 7-178 等都將被排除。

如此以上猜想成立的話,就有可能導致開頭的BUG。 前提是wordstop當中包含-

在innodb當中,stopword可以透過INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD表來檢視。
可以透過此表來自定義刪除或新增stopword,從而改變分詞規則。

透過檢視,可以發現'-'並不在stopword當中,所以上面的猜想是錯誤的,並不是這個原因導致的BUG。

mysql> SELECT * FROM INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD;
+-------+
| value |
+-------+
| a     |
| about |
| an    |
| are   |
| as    |
| at    |
| be    |
| by    |
| com   |
| de    |
| en    |
| for   |
| from  |
| how   |
| i     |
| in    |
| is    |
| it    |
| la    |
| of    |
| on    |
| or    |
| that  |
| the   |
| this  |
| to    |
| was   |
| what  |
| when  |
| where |
| who   |
| will  |
| with  |
| und   |
| the   |
| www   |
+-------+
36 rows in set (0.00 sec)

布林模式的邏輯運算子


mysql全文檢索有兩種最常用的方式。自然語言模式和布林模式。


自然語言模式


對於自然語言模式搜尋,搜尋項被轉換為ngram項的並集。例如,字串abc(假設ngram_token_size=2)被轉換為ab bc。給定兩個文件,一個包含ab,另一個包含abc,搜尋詞ab bc匹配這兩個文件。

可以簡單的理解為,將搜尋關鍵詞再拆分,與文件進行模式匹配。

上圖所示,文件中包含12和'0997'都被命中了。


布林模式


對於布林模式搜尋,搜尋項被轉換為ngram短語搜尋。例如,字串abc(假設ngram_token_size=2)被轉換為ab bc。給定兩個文件,一個包含ab,另一個包含abc,搜尋短語ab bc只匹配包含abc的文件。

可以理解為不會對關鍵詞進行再拆分,相當於對搜尋關鍵詞進行全匹配。

使用相同的測試資料和相當的搜尋關鍵詞,使用布林模式搜尋。

結果為空。 沒有資料被命中。


但是

在布林模式下搜尋0797-12345命中了0797-946490797-1789
但不會命中'07','09','12'等。

我只能解釋為,布林模式下,搜尋關鍵詞0797-12345中的'-'被當成語法了,導致無形中被拆分成了079712345兩部份。

但是,我從mysql官網沒有找到證據。 所以此點存疑。各位看官要有自己的思考,不要被我誤導!

跟上一小節當中'7-'沒有命中任何記錄一樣,也是布林模式下語法的原因。

現在我們來討論一下布林模式下的邏輯運算子問題。

布林模式的邏輯運算子

  1. +
    select * from t_user where match(phone) AGAINST('a +b' in boolean mode)
    其中 + 會被識別成邏輯運算子,而不是將a +b作為一個整體,以下同理。
    'a +b' 指'a'和'b'必須同時出現才滿足搜尋條件。
  2. -
    select * from t_user where match(phone) AGAINST('0797 -12345' in boolean mode)
    0797 -123450797必須包含,但不包含12345才能滿足搜尋條件。
    以下查詢排除了包含0797-12345的記錄。

    注意-前後空格 0797 -12345才表示包含0797 同時不包含12345.
    0797-12345等於0797 - 12345,它並不等於0797 -12345
    有圖為證:

  3. > <
    提高/降低該條匹配資料的權重值。不管使用>還是 <,其權重值均大於沒使用其中任何一個的。
    select * from t_user where match(phone) AGAINST('0797(>94649 <12345)' in boolean mode)
    表示匹配0797,同時包含94649的列往前排,包含12345的往後排
    select * from t_user where match(phone) AGAINST('a > b' in NATURAL LANGUAGE mode)
  4. ()
    相當於表示式分組,參考上一個例子。
  5. *
    萬用字元,只能在字串後面使用
  6. "
    完全匹配,被雙引號包起來的單詞必須整個被匹配。
    select * from t_user where match(phone) AGAINST('"0797-1789"' in boolean mode)
    "0797-1789"中不可再分。其它包含0797-1234等記錄就不再匹配。

解決方案


現在,讓我們回到最初的美好。
我們遇到了一個問題,一個座機號0979-1789全文檢索返回了不完全匹配的記錄。

那麼,想要完全匹配,需要怎麼做呢。
經過上面的旅程,我們有了兩種方案。

  1. 使用 ""
    將座機號包起來,"0979-1789",表示此搜尋關鍵詞不可再分。自然就能全匹配。
  2. 主動拆分,再使用+
    我們知道,之所以座機號能將不完全匹配的記錄查詢出來,是因為將座機號當中的"-"當成了邏輯運算子,從而導致了座機號被拆分成了兩部份。
    那我們先主動將座機號拆分兩部份,再使用邏輯運算子"+",表示兩部份都必須包含才能返回。

建議使用第一種方法。

其它的電話號碼錶示方法,比如區號+電話號碼,023+12345678,國際長途0086-10-1234567或+86-573-82651630,610-643-4567等。
這裡面涉及到+-等邏輯運算子,用第一種方法最安全。

倒排索引

全文索引即是倒排索引。
好像這種說法,在lucene或者elasticsearch更流行。

文末還是簡單說一下它的原理。


傳統資料庫索引的方式是,【表->欄位】。而倒排索引的方式是先將欄位進行分詞,然後將單詞跟文件進行關聯,變為【文件 -> 單詞】,並將記錄其它更為強大的資訊(文件編號、詞項頻率、詞項的位置、詞項開始和結束的字元位置可以被儲存)。

 有兩篇文章:

1 我是程式設計師

2 我熱愛寫程式

先分詞(這裡假設以自然語義分詞)

1 【我】【是】【程式】【程式設計師】

2 【我】【熱愛】【寫】【程式】

 

前面文章對關鍵字,經倒排後變成關鍵字對文章

 

關鍵字 文章號
1,2
1
程式 1,2
程式設計師 1
熱愛 2
2

 

為了快速定位和節省儲存大小,還需要加上關鍵字出現頻率和位置。  

關鍵字 文章號(頻率) 位置
1(1) 1
  2(1) 1
1(1) 2
程式 1(1) 3
  2(1) 4
程式設計師 1(1) 4
熱愛 1(1) 1
1(1) 3

如果我要對“程式”進行搜尋,能就能快速定位到文件1,2,並且能直接知道它在文件當中出現了多少次,分別出現在哪裡。

小結

關於分詞,mysql有兩種引擎,一種是基於空格的拉丁語系模式,預設就是這種。如'i love you'拆分為i love you三部份。
在5.7.6以後,針對中日韓文字內建了一種基於長度的分詞器,n_gram parser。
此分詞器並不區分中文和阿拉伯數字,兩種文字分詞的標準是一樣的。
但一些特殊的文字里面帶有布林模式下的邏輯運算子(+-><*())的時候需要特別注意。


同時,mysql全文索引本身有很多限制,該用elasticsearch的時候也該大膽上:

1:只支援char、varchar、text型別。
2:MySQL的全文索引只有全部在記憶體中的時候,效能才非常好。如果記憶體無法裝載全部索引,那麼效能可能會非常慢(可以為全文索引設定單獨的鍵快取(key cache),保證不會被其他的索引快取擠出記憶體)
3:相比其它的索引型別,當insert、update和delete操作進行時,全文索引的操作代價非常大。而且全文索引會有更多的碎片,可能需要做更多的optimize table操作。
4:全文索引優先順序在索引中最高,即便這時有更合適的索引可用,MySQL也會放棄效能比較,優先使用全文索引。
5:全文索引不儲存索引列的實際值,也就不可能用作索引覆蓋掃描。
6:除了相關性排序,全文索引不能用作其他的排序。如果查詢需要做相關性以外的排序操作,都需要使用檔案排。



參考:
https://dev.mysql.com/doc/refman/8.0/en/fulltext-search-ngram.html
https://dev.mysql.com/doc/refman/8.0/en/fulltext-stopwords.html

相關文章