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

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

以下不是傳統的從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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章