iOS/Android SQLite 全文檢索——FTS (Full Text Search)
前言
我們的APP部分功能為了滿足使用者離線使用搜索的場景,使用了內建SQLite資料庫的方式,隨著內容的日益豐富,資料庫記錄快速增多,導致搜尋速度明顯變慢,為了提升搜尋速度,給我們的資料做了全文檢索的支援,在3W+的資料下,搜尋速度由原來的數秒提升至幾十到幾百毫秒(裝置不同,搜尋效率存在差別)。
一、基本概念
概述
全文檢索是從文字或資料庫中,不限定資料欄位,自由地搜尋出訊息的技術。
執行全文檢索任務的程式,一般稱作搜尋引擎,它可以將使用者隨意輸入的文字從資料庫中找到匹配的內容。工作原理
它的工作原理是計算機索引程式通過掃描文章中的每一個詞,對每一個詞建立一個索引,指明該詞在文章中出現的次數和位置,當用戶查詢時,檢索程式就根據事先建立的索引進行查詢,並將查詢的結果反饋給使用者的檢索方式。分類
按字檢索
指對於文章中的每一個字都建立索引,檢索時將詞分解為字的組合。按詞檢索
指對文章中的詞,即語義單位建立索引,檢索時按詞檢索。
注意:
在中文裡面,每個漢字都有單獨的含義,而英文中最小的語義單位是詞,所以在英文搜尋中按字搜尋和按詞搜尋並沒有明顯的區分。
二、為什麼使用SQLite全文檢索
在SQLite對全文檢索的官方介紹中的開篇,有下面一段內容:
For example, if each of the 517430 documents in the “Enron E-Mail Dataset” is inserted into both an FTS table and an ordinary SQLite table created using the following SQL script:
CREATE VIRTUAL TABLE enrondata1 USING fts3(content TEXT); /* FTS3 table */
CREATE TABLE enrondata2(content TEXT); /* Ordinary table */
Then either of the two queries below may be executed to find the number of documents in the database that contain the word “linux” (351). Using one desktop PC hardware configuration, the query on the FTS3 table returns in approximately 0.03 seconds, versus 22.5 for querying the ordinary table.
SELECT count(*) FROM enrondata1 WHERE content MATCH 'linux'; /* 0.03 seconds */
SELECT count(*) FROM enrondata2 WHERE content LIKE '%linux%'; /* 22.5 seconds */
Of course, the two queries above are not entirely equivalent. For example the LIKE query matches rows that contain terms such as “linuxophobe” or “EnterpriseLinux” (as it happens, the Enron E-Mail Dataset does not actually contain any such terms), whereas the MATCH query on the FTS3 table selects only those rows that contain “linux” as a discrete token. Both searches are case-insensitive. The FTS3 table consumes around 2006 MB on disk compared to just 1453 MB for the ordinary table. Using the same hardware configuration used to perform the SELECT queries above, the FTS3 table took just under 31 minutes to populate, versus 25 for the ordinary table.
在相同的裝置環境下,包含 517430條 記錄的SQLite資料庫中,使用全文檢索FTS3建立的資料庫 MATCH
查詢耗時0.03秒,沒有使用全文檢索的資料庫,使用 LIKE
查詢耗時22.5秒。
三、版本選擇
SQLite提供的FTS(Full Text Search)模組,就是用來支援全文檢索的。
FTS從1到5已經發展了5個版本,1和2已經廢棄了。
通常情況下使用最新的版本一般效能上會有最好的優化,這樣看FTS5似乎是最好的選擇。但是由於FT5
需要SQLite 3.9.0以上支援,iOS 9內建的SQLite版本還是3.8.8,而且FTS5暫時沒有對中文支援比較好的分詞器,所以簡單起見可以考慮FTS4
或者FTS3
。FTS3/FTS4
是比較常用的版本,效能上,50W條記錄搜尋耗時0.03秒,對移動端來說效率已經能滿足使用者的體驗需求。
當然,如果想支援FTS5
,可以不使用系統自帶的SQLite版本,直接在Podfile下加入最新的支援FTS5
的sqlite3
版本即可:
pod 'sqlite3/fts5'
四、分詞器
FTS3
和 FTS4
提供四種系統分詞器,除了系統分詞器外,也可以自定義分詞器,這裡主要介紹系統分詞器。
型別 | 是否支援中文 | 特性 | 注意 |
---|---|---|---|
simple | 否 | 連續的合法字元(unicode大於等於128)和數字組詞 | 全都會轉換為小寫字母 |
porter | 否 | 同上,支援生成原語義詞(如一個語義的動詞、名詞、形容詞會生成統一的原始詞彙) | 同上 |
icu | 是 | 多語言,需要指明本地化語言,根據語義進行分詞(如“北京歡迎你”,可以分為“北”、“北京”、“歡迎”、“歡迎你”等詞彙) | 可以自定義分詞規則 |
unicode61 | 是 | 特殊字元分詞,(unicode的空格+字元,如“:”、“-”等) | 只能處理ASCII字元,需要SQLite 3.7.13及以上 |
五、使用步驟
1.建立 VIRTUAL TABLE
預設分詞
-- Create an FTS table named "pages" with three columns:
CREATE VIRTUAL TABLE pages USING fts4(title, keywords, body);
指定分詞
-- Create an FTS table with a single column - "content" - that uses
-- the "porter" tokenizer.
CREATE VIRTUAL TABLE data USING fts4(tokenize= porter);
2.遷移資料
// 插入一條記錄
INSERT INTO pages(docid, keywords, title, body) VALUES(53, ‘Home Page’ 'Home Page', 'SQLite is a software...');
// 更新一條記錄
UPDATE pages SET title = 'Download SQLite' WHERE rowid = 54;
// 刪除所有記錄
DELETE FROM pages;
3.全文檢索查詢
// 全表匹配
SELECT * FROM pages WHERE pages MATCH ‘sqlite’;
// 按列匹配
SELECT * FROM pages WHERE title MATCH 'sqlite';
SELECT * FROM pages WHERE keywords MATCH ‘sqlite';
SELECT * FROM pages WHERE body MATCH 'sqlite';
六、MATCH
部分語法
FTS
中MATCH
右側的表示式支援 AND/OR/NEAR/NOT
等運算,注意,需要區分大小寫,小寫不不可以的。
AND
AND
用來連線兩個想要匹配的關鍵詞,所查詢到的結果必須同時包含AND
連線的兩個關鍵詞。
// 搜尋pages表body列中同時包含紅和藍的資料
SELECT * FROM pages WHERE body MATCH 'blue AND red';
OR
與
AND
類似,它也連線兩個想要匹配的關鍵詞,不同的是,結果只要包含二者之一即可。// 搜尋pages表body列中同時包含白或綠的資料 SELECT * FROM pages WHERE body MATCH 'white OR green';
NOT
NOT
也連線兩個想要匹配的關鍵詞,它匹配的結果包含前一個關鍵詞、且不包含第二個關鍵詞。// 搜尋pages表body列中同時包含白,但是不包含綠的資料 SELECT * FROM pages WHERE body MATCH 'white NOT green';
注意:
NOT
不能單獨使用,必須連線兩個關鍵詞。// 錯誤寫法:搜尋pages表body列中所有不包含綠的資料 SELECT * FROM pages WHERE body MATCH 'NOT green';
NEAR
NEAR
也連線兩個想要匹配的關鍵詞,它匹配的結果同時包含兩個關鍵詞,但是結果裡面的這兩個關鍵詞中間預設必須不多餘10個根據分詞器分出的詞。另外NEAR
可以指定最小的間隔數量,NEAR/5
即指定間隔數最大為5。// 搜尋t_guides_terms表所有列中同時包含“科”和“南”的記錄,他們中間不多於一個分詞結果。 select * from t_guides_terms where t_guides_terms match '科 NEAR/1 南';;
七、Demo
simple分詞
--simple tokenize create VIRTUAL TABLE t_pages USING fts4(title, body); --insert insert into t_pages(title, body) VALUES ('Hello world', 'Hello world! It is a good day!'); insert into t_pages(title, body) VALUES ('Hello world2', 'Hello world2! It is a good day too!'); insert into t_pages(title, body) VALUES ('Hello world-h', 'Hello world-j! It is a good day!'); insert into t_pages(title, body) VALUES ('How are you', 'The dog is interesting'); update t_pages set title = 'What is that' where title = 'Hello world2'; --all table columns match --前文提到連續的合法字元被分詞,helle所有字元都合法,它與下個詞world有空格,空格不是合法字元,會以空格分開分詞——“hello”和“world” select * from t_pages where t_pages match 'hello'; --*字元可以做模糊匹配的萬用字元,類似like的% select * from t_pages where t_pages match 'hell*'; --hell不會被分詞,所以沒有結果 select * from t_pages where t_pages match 'hell'; select * from t_pages where t_pages match 'day*'; select * from t_pages where t_pages match 'day'; SELECT * FROM t_pages WHERE t_pages MATCH 'world'; SELECT * FROM t_pages WHERE t_pages MATCH 'world2'; --terms SELECT * FROM t_pages WHERE t_pages MATCH 'hello world'; --這裡有結果 SELECT * FROM t_pages WHERE t_pages MATCH 'world-h hello'; --這裡沒結果,因為使用“"”包括的字元不進行分詞,所以沒有結果 SELECT * FROM t_pages WHERE t_pages MATCH '"world-h hello"'; --column match select * from t_pages where title match 'hello'; select * from t_pages where title match 'what'; --match vs like --match可以任意交換關鍵字順序,不影響搜尋結果,like會沒有結果 --macth可以簡單的做全表匹配,而like需要指定對應的列 select * from t_pages where t_pages match 'that what'; select * from t_pages where title like '%what%that%'; select * from t_pages where title like '%that%what%'; --OR AND NEAR NOT SELECT title, body FROM t_pages WHERE t_pages MATCH 'hello AND world'; --AND和直接搜兩個關鍵詞結果一致 SELECT title, body FROM t_pages WHERE t_pages MATCH 'hello world'; SELECT title, body FROM t_pages WHERE t_pages MATCH '(hello NEAR world) OR (program AND language)'; SELECT title, body FROM t_pages WHERE t_pages MATCH 'It NEAR is'; --包含hello但不包含world2的 SELECT title, body FROM t_pages WHERE t_pages MATCH 'hello NOT world2';
porter分詞
--porter tokenize create virtual table t_books using fts4(title, description, content, tokenize=porter); insert into t_books(title, description, content) values ('Who can who up', 'No can no bibi', 'To be No1'); insert into t_books(title, description, content) values ('How are you', 'I''am fine', 'The dog is interesting'); --all table columns match select * from t_books where t_books match 'who'; select * from t_books where t_books match 'bibi*'; select * from t_books where t_books match 'dog'; select * from t_books where t_books match 'is'; --column match select * from t_books where title match 'how'; select * from t_books where content match 'how'; --simple vs porter --無結果 select * from t_pages where t_pages match 'interested'; --有結果,porter分詞會將interesting轉換為原詞interest做索引儲存,搜尋interest和interested都有結果 select * from t_books where t_books match 'interest'; select * from t_books where t_books match 'interested';
unicode-61分詞
--unicode61,不分詞 CREATE VIRTUAL TABLE t_guides USING fts4(title, content, tokenize=unicode61) insert into t_guides(title, content) VALUES ('醫學指南', '我是一篇指南'); --no terms --有結果,因為匹配全表 select * from t_guides where t_guides match '醫學指南'; --無結果 select * from t_guides where content match '醫學指南'; select * from t_guides where content match '我是一篇指南'; --無結果,前文講到,以空格個特殊字元分詞,插入的記錄沒有空格和特殊字元,所以欄位內容整個做分詞 select * from t_guides where content match '我是一篇'; --模糊匹配有結果 select * from t_guides where content match '我是一篇*'; --unicode61,文字間加特殊字元,用以分詞 CREATE VIRTUAL TABLE t_guides_terms USING fts4(title, content, tokenize=unicode61) insert into t_guides_terms(title, content) VALUES ('醫 學 指 南', '我 是 一 篇 指 南'); insert into t_guides_terms(title, content) VALUES ('骨|科|指|南', '第|二|篇|指|南'); insert into t_guides_terms(title, content) VALUES ('專|科|指|南', '專|二|篇|指|南'); --terms --無結果,因為關鍵詞沒有空格或者特殊字元隔開,無法分詞 select * from t_guides_terms where t_guides_terms match '醫學指南'; --有結果,空格可以分詞 select * from t_guides_terms where t_guides_terms match '醫 學 指'; select * from t_guides_terms where content match '一 篇'; select * from t_guides_terms where content match '一 南'; select * from t_guides_terms where content match '"一 南"'; select * from t_guides_terms where content match '"一 篇"'; select * from t_guides_terms where content match '一'; --區分大小寫,無結果 select * from t_guides_terms where t_guides_terms match '科 and 骨'; -- 有結果,包含科和骨 select * from t_guides_terms where t_guides_terms match '科 NOT 骨'; --有結果 select * from t_guides_terms where t_guides_terms match '科 NEAR/6 南'; --無結果,科和南中間隔了一個字元 select * from t_guides_terms where t_guides_terms match '科 NEAR/0 南';
icu分詞(中文)
--icu CREATE VIRTUAL TABLE t_school USING fts4(name, content, tokenize=icu zh_CN); --drop table t_school; insert into t_school(name, content) VALUES ('北京第一實驗小學', '我是北京第一實驗小學'); insert into t_school(name, content) VALUES ('河北京東', '我是河北京東'); --有結果,icu按語義分詞,“北京”是一個有語義的詞 select * from t_school where t_school match '北京'; --無結果(猜測是詞庫不夠全,可以自定義分詞來解決) select * from t_school where t_school match '京東'; select * from t_school where t_school match '河'; --有結果,icu按語義分詞,“第一實驗小學”是一個有語義的詞 select * from t_school where t_school match '第一實驗小學'; select * from t_school where t_school match '實驗小學'; select * from t_school where t_school match '北京第'; select * from t_school where t_school match '北京第一'; select * from t_school where t_school match '第一實';