說到全文檢索的分詞,多半講到的是中(日韓)文分詞,少有英文等拉丁文系語言,因為英語單詞天然就是分詞的。
但更少講到阿拉伯數字。比如金額,手機號碼,座機號碼等等。
以下不是傳統的從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;
當使用手機模糊查詢關聯使用者時可使用以下語句。
- 布林模式模糊檢索
select * from t_user where match(phone) AGAINST('13996459860' in boolean mode)
- 自然語言模式。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
programmer
3個單詞,這也就是前文說的英語天然沒有分詞的問題。
但對於像中文這類不以空格拆分詞語的語言來說無法適用。
因此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分成了0797
和12345678
兩部份。
但透過這一小節的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
就被拆分成兩個大的部份,0797
和1789
。
其中 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-94649
和0797-1789
。
但不會命中'07','09','12'等。
我只能解釋為,布林模式下,搜尋關鍵詞0797-12345
中的'-'被當成語法了,導致無形中被拆分成了0797
和12345
兩部份。
但是,我從mysql官網沒有找到證據。 所以此點存疑。各位看官要有自己的思考,不要被我誤導!
跟上一小節當中'7-'沒有命中任何記錄一樣,也是布林模式下語法的原因。
現在我們來討論一下布林模式下的邏輯運算子問題。
布林模式的邏輯運算子
+
select * from t_user where match(phone) AGAINST('a +b' in boolean mode)
其中 + 會被識別成邏輯運算子,而不是將a +b
作為一個整體,以下同理。
'a +b' 指'a'和'b'必須同時出現才滿足搜尋條件。-
select * from t_user where match(phone) AGAINST('0797 -12345' in boolean mode)
0797 -12345
指0797
必須包含,但不包含12345
才能滿足搜尋條件。
以下查詢排除了包含0797-12345
的記錄。
注意-前後空格0797 -12345
才表示包含0797
同時不包含12345
.
0797-12345
等於0797 - 12345
,它並不等於0797 -12345
。
有圖為證:
>
<
提高/降低該條匹配資料的權重值。不管使用>
還是<
,其權重值均大於沒使用其中任何一個的。
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)
()
相當於表示式分組,參考上一個例子。*
萬用字元,只能在字串後面使用"
完全匹配,被雙引號包起來的單詞必須整個被匹配。
select * from t_user where match(phone) AGAINST('"0797-1789"' in boolean mode)
"0797-1789"
中不可再分。其它包含0797-1234等記錄就不再匹配。
解決方案
現在,讓我們回到最初的美好。
我們遇到了一個問題,一個座機號0979-1789
全文檢索返回了不完全匹配的記錄。
那麼,想要完全匹配,需要怎麼做呢。
經過上面的旅程,我們有了兩種方案。
- 使用 ""
將座機號包起來,"0979-1789"
,表示此搜尋關鍵詞不可再分。自然就能全匹配。
- 主動拆分,再使用+
我們知道,之所以座機號能將不完全匹配的記錄查詢出來,是因為將座機號當中的"-"當成了邏輯運算子,從而導致了座機號被拆分成了兩部份。
那我們先主動將座機號拆分兩部份,再使用邏輯運算子"+",表示兩部份都必須包含才能返回。
建議使用第一種方法。
其它的電話號碼錶示方法,比如區號+電話號碼,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