微信全文搜尋耗時降94%?我們用了這種方案

騰訊雲開發者發表於2023-02-23

圖片

導語 |微信終端涉及到大量文字搜尋的業務場景,主要包括聯絡人搜尋、聊天記錄搜尋和收藏搜尋等。近期微信團隊對 IOS 微信的全文搜尋技術進行了一次全面升級,本文將分享其選型與最佳化思路,詳細解析全文搜尋的應用資料庫表格式、索引更新和搜尋邏輯的最佳化細節。希望本文對你有幫助。

目錄

1 IOS 微信全文搜尋技術的現狀

2 全文搜尋引擎的選型與最佳化

2.1 搜尋引擎選型

2.2 實現 FTS5 的 Segment 自動 Merge 機制

2.3 分詞器最佳化

2.4 索引內容支援多級分隔符

3 全文搜尋應用邏輯最佳化

3.1 資料庫表格式最佳化

3.2 索引更新邏輯最佳化

3.3 搜尋邏輯最佳化

4 總結

01、IOS 微信全文搜尋技術的現狀

全文搜尋是使用倒排索引進行搜尋的一種搜尋方式。倒排索引也稱為反向索引——對輸入的內容中的每個 Token 建立一個索引,索引中儲存了這個 Token 在內容中的具體位置。全文搜尋技術主要應用在對大量文字內容進行搜尋的場景。

微信終端涉及到大量文字搜尋的業務場景主要包括聯絡人、聊天記錄、收藏。這些搜尋功能多年沒有更新底層搜尋技術,聊天記錄使用的全文搜尋引擎還是 SQLite FTS3,而現在已經有 SQLite FTS5;收藏首頁的搜尋還是使用簡單的 Like 語句來匹配文字;聯絡人搜尋甚至用的是記憶體搜尋(在記憶體中遍歷所有聯絡人的所有屬性進行匹配)。

使用者在微信上積累的資料越來越多,提升微信底層搜尋技術的需求也越來越迫切。在近幾年,業務團隊對 IOS 微信的全文搜尋技術進行了一次全面升級,本文主要介紹技術升級的工作經驗。

02、全文搜尋引擎的選型與最佳化

2.1 搜尋引擎選型

IOS 客戶端可以使用的全文搜尋引擎並不多,主要有 SQLite 三個版本的 FTS 元件、Lucene 的 C++實現版本 CLucene 和 C 語言橋接版本 Lucy。這裡給出了這些引擎在事務能力、技術風險、搜尋能力、讀寫效能等方面的比較。

圖片

  • 事務能力方面:Lucene 沒有提供完整的事務能力,因為 Lucene 使用了多檔案的儲存結構,沒有保證事務的原子性。SQLite 的 FTS 元件因為底層使用普通的表來實現,可以完美繼承 SQLite 的事務能力。
  • 技術風險方面:Lucene 主要應用於服務端,在客戶端沒有大規模應用的案例。自 2013 年起 CLucene 和 Lucy 官方都停止維護了,技術風險較高。SQLite 的 FTS3 和 FTS4 元件則是屬於 SQLite 的舊版本引擎,官方維護不多,而且這兩個版本都是將一個詞的索引存到一條記錄中,極端情況下有超出 SQLite 單條記錄最大長度限制的風險。SQLite 的 FTS5 元件作為最新版本引擎已推出超六年,在安卓微信上也已經全量應用,所以其技術風險是最低的。
  • 搜尋能力方面:Lucene 的發展歷史比 SQLite 的 FTS 元件長很多,搜尋能力相比而言最豐富。Lucene 有豐富的搜尋結果評分排序機制,但這個在微信客戶端沒有應用場景。因為微信搜尋結果要麼是按照時間排序,要麼是按照一些簡單的自定義規則排序。在 SQLite 幾個版本的引擎中,FTS5 的搜尋語法更加完備嚴謹。它提供了很多介面給使用者自定義搜尋函式,所以搜尋能力相對強一點。
  • 讀寫效能方面,下面是用不同引擎對 100 萬條長度為 10 的隨機生成中文語句生成 Optimize 狀態的索引的效能資料,其中每個語句的漢字出現頻率按照實際的漢字使用頻率:

圖片

圖片

圖片

可以看到,Lucene 讀取命中數量的效能比 SQLite 好很多,說明 Lucene 索引的檔案格式很有優勢。但是微信沒有隻讀取命中數量的應用場景,Lucene 的其他效能資料跟 SQLite 的差距不明顯。SQLite FTS3 和 FTS5 的大部分效能很接近,FTS5 索引的生成耗時比 FTS3 高一截,但這個有最佳化方法。

綜合考慮這些因素,我們選擇 SQLite FTS5 作為 IOS 微信全文搜尋的搜尋引擎。

2.2 實現 FTS5 的 Segment 自動 Merge 機制

SQLite FTS5 會把每個事務寫入的內容儲存成一個獨立的 b 樹,稱為一個segment。segment 中儲存了本次寫入內容中每個詞的行號(rowid)、列號和欄位中每次出現的位置偏移,所以這個 segment 就是該內容的倒排索引。多次寫入就會形成多個 segment 。查詢時需要分別查詢這些 segment 再彙總結果。從而, segment 數量越多,查詢速度越慢。

為了減少 segment 的數量,SQLite FTS5 引入了 merge 機制。新寫入的 segment 的 level 為 0,merge 操作可以把level為i的現有 segment 合併成一個 level 叫 i+1的新的 segment 。

merge 的示例如下:

圖片

FTS5 預設的merge操作有兩種:

  • 某一個 level 的 segment 達到4時,就開始在寫入內容時自動執行一部分 merge 操作,稱為一次 automerge 。每次 automerge 的寫入量跟本次更新的寫入量成正比,需要多次 automerge 才能完整合併成一個新 segment 。automerge 在完整生成一個新的 segment 前,需要多次裁剪舊的 segment 的已合併內容,引入多餘的寫入量。
  • 本次寫入後某一個 level 的 segment 數量達到 16 時,一次性合併這個 level 的 segment ,稱為 crisismerge

FTS5 的預設 merge 操作都是在寫入時同步執行的。這會對業務邏輯造成效能影響,特別是 crisismerge 會偶然導致某一次寫入操作特別久,這會讓業務效能不可控。之前的測試中 FTS5 的建索引耗時較久,也主要因為 FTS5 的 merge 操作比其他兩種引擎更加耗時。

我們在 WCDB 中實現 FTS5 的 segment 自動 merge 機制,將這些 merge 操作集中到一個單獨子執行緒執行,並且最佳化執行引數,具體如下:

  • 首先,監聽有 FTS5 索引的資料庫每個事務變更到的 FTS5 索引表,拋通知到子執行緒觸發 WCDB 的自動 merge 操作。
  • 其次,merge 執行緒檢查所有 FTS5 索引表中 segment 數,超過 1 的 level 執行一次 merge 。
  • 最後,merge時每寫入 16 頁資料檢查一次有沒有其他執行緒的寫入操作因為 merge 操作阻塞,如果有就立即 commit ,儘量減小 merge 對業務效能的影響。

自動 merge 邏輯執行的流程如下:

圖片

限制每個 level 的 segment 數量為1,可以讓 FTS5 的查詢效能最接近 optimize (所有segment 合併成一個)之後的效能,而且引入的寫入量是可接受的。假設業務每次寫入量為 M 、寫入了 N 次,那麼在 merge 執行完整之後,資料庫實際寫入量為**MN(log2(N)+1)** 。業務批次寫入,提高 M 也可以減小總寫入量。

效能方面,一個包含 100w 條中文內容、每條長度 100 漢字的 FTS5 的表查詢三個詞, optimize 狀態下耗時2.9m。分別限制每個 level 的 segment 數量為2、3、4時,查詢耗時分別為4.7ms、8.9ms、15ms 。100w 條內容每次寫入 100 條的情況下,按照 WCDB 的方案執行 merge ,耗時在10s內。

使用自動 merge 機制,可以在不影響索引更新效能的情況下,將 FTS5 索引保持在最接近 optimize 的狀態,提高了搜尋速度。

2.3 分詞器最佳化

2.3.1 分詞器效能最佳化

分詞器是全文搜尋的關鍵模組,它將輸入內容拆分成多個 Token 並提供這些 Token 的位置,搜尋引擎再對這些 Token 建立索引。SQLite 的 FTS 元件支援自定義分詞器,可以按照業務需求實現自己的分詞器。

分詞器的分詞方法可以分為按字分詞和按詞分詞。前者只是簡單對輸入內容逐字建立索引,後者則需要理解輸入內容的語義,對有具體含義的片語建立索引。相比於按字分詞,按詞分詞的優勢是既可以減少建索引的 Token 數量,也可以減少搜尋時匹配的 Token 數量;劣勢是需要理解語義,而且使用者輸入的詞不完整時,也會有搜不到的問題。

為了簡化客戶端邏輯、避免使用者漏輸內容時搜不到,IOS 微信之前的 FTS3 分詞器 OneOrBinaryTokenizer 採用了一種巧妙的按字分詞演算法:除了對輸入內容逐字建索引,還會對內容中每兩個連續的字建索引,對於搜尋內容則是按照每兩個字進行分詞。

下面是一個用“北京歡迎你”去搜尋相同內容的分詞例子:

圖片

相比於簡單的按字分詞,這種分詞方式的優勢是可以將搜尋時匹配的 Token 數量降低近一半,提高搜尋速度,而且在一定程度上可以提升搜尋精度。比如搜尋“歡迎你北京”就匹配不到“北京歡迎你”。

這種分詞方式的劣勢是儲存的索引內容很多,基本輸入內容的每個字都在索引中儲存了三次,是一種用空間換時間的做法。

OneOrBinaryTokenizer 用接近三倍的索引內容增長才換取不到兩倍的搜尋效能提升,並不是很划算。所以我們在 FTS5 上重新開發了一種新的分詞器 VerbatimTokenizer ——這個分詞器只採用基本的按字分詞,不儲存冗餘索引內容。在搜尋時,每兩個字用引號引起來組成一個 Phrase 。按照 FTS5 的搜尋語法,搜尋時Phrase中的字要按順序相鄰出現的內容才會命中,實現了跟 OneOrBinaryTokenizer 一樣的搜尋精度。

VerbatimTokenizer 的分詞規則示意圖如下:

圖片

2.3.2 分詞器能力擴充套件

VerbatimTokenizer 還根據微信實際的業務需求,實現了五種擴充套件能力來提高搜尋的容錯能力:

  • 第一,支援在分詞時將繁體字轉換成簡體字。這樣使用者可以用繁體字搜到簡體字內容,用簡體字也能搜到繁體字內容,避免了因為漢字的簡體和繁體字形相近導致使用者輸錯的問題。
  • 第二,支援 Unicode 歸一化。Unicode 支援相同字形的字元用不同的編碼來表示。比如編碼為\ ue9 的 é 和編碼為\ u65 \ u301 的 é 有相同的字形,這會導致使用者用看上去一樣的內容去搜但卻搜不到的問題。Unicode 歸一化就是把字形相同的字元用同一個編碼表示。
  • 第三,支援過濾符號。大部分情況下,我們不需要支援對符號建索引,符號的重複量大而且使用者一般也不會用符號去搜尋內容。但是聯絡人搜尋這個業務場景需要支援符號搜尋,因為使用者的暱稱裡面經常出現顏文字,符號的使用量不低。
  • 第四,支援用Porter Stemming演算法對英文單詞取詞幹。取詞幹的好處是允許使用者搜尋內容的單複數和時態跟命中內容不一致,讓使用者更容易搜到內容。但是取詞幹也有弊端,比如使用者要搜尋的內容是 “happyday” ,輸入 “happy” 作為字首去搜尋卻會搜不到,因為 “happyday” 取詞幹變成 “happydai” , “happy” 取詞幹變成 “happi” ,後者就不能成為前者的字首。這種 badcase 在內容為多個英文單詞拼接一起時容易出現。聯絡人暱稱的拼接英文很常見,所以在聯絡人的索引中沒有取詞幹,在其他業務場景中都用上了。
  • 第五,支援將字母全部轉成小寫。這樣使用者可以用小寫搜到大寫,反之亦然。

這些擴充套件能力都是對建索引內容和搜尋內容中的每個字做變換,這個變換其實也可以在業務層做。其中的 Unicode 歸一化和簡繁轉換以前就是在業務層實現的。但是這樣做有兩個弊端,一個是業務層每做一個轉換都需要對內容做一次遍歷,引入冗餘計算量。另一個弊端是寫入到索引中的內容是轉變後的內容,那麼搜尋出來的結果也是轉變後的,會和原文不一致,業務層做內容判斷的時候容易出錯。鑑於這兩個原因, VerbatimTokenizer 將這些轉變能力都集中到了分詞器中實現。

2.4 索引內容支援多級分隔符

SQLite 的 FTS 索引表不支援在建表後再新增新列。但是隨著業務的發展,業務資料支援搜尋的屬性會變多,如何解決新屬性的搜尋問題呢?特別是在聯絡人搜尋這個業務場景,一個聯絡人支援搜尋的欄位非常多。一個直接的想法是將新屬性和舊屬性用分隔符拼接到一起建索引。但這樣會引入新的問題,FTS5 是以整個欄位的內容作為整體去匹配的,如果使用者搜尋匹配的 Token 在不同的屬性,那這條資料也會命中,這個結果顯然不是使用者想要的,搜尋結果的精確度就降低了。

我們需要搜尋匹配的 Token 中間不存在分隔符,這樣可以確保匹配的 Token 都在一個屬性內。為了支援業務靈活擴充套件,還需要支援多級分隔符。搜尋結果中還要支援獲取匹配結果的層級、位置以及該段內容的原文和匹配詞。

這個能力 FTS5 還沒有,而 FTS5 的自定義輔助函式支援在搜尋時獲取到所有命中結果中的每個命中 Token 位置。利用這個資訊可以推斷出這些 Token 中間有沒有分隔符,以及這些 Token 所在的層級,所以我們開發了 SubstringMatchInfo 這個新的 FTS5 搜尋輔助函式來實現這個能力。

函式的大致執行流程如下:

圖片

03、全文搜尋應用邏輯最佳化

3.1 資料庫表格式最佳化

3.1.1 非文字搜尋內容的儲存方*

在實際應用中,我們除了要在資料庫中儲存需要搜尋的文字的 FTS 索引,還需要額外儲存這個文字對應的業務資料的 id 、用於結果排序的的屬性(常見的是業務資料的建立時間)以及其他需要直接跟隨搜尋結果讀出的內容,這些都是不參與文字搜尋的內容。根據非文字搜尋內容的不同儲存位置,我們可以將 FTS 索引表的表格式分成兩種:

第一種方式是將非文字搜尋內容儲存在額外的普通表中,這個表儲存 FTS 索引的 Rowid 和非文字搜尋內容的對映關係,而 FTS 索引表的每一行只儲存可搜尋的文字內容,這個表格式類似於這樣:

圖片

這種表格式的優勢是 FTS 索引表的內容很簡單,不熟悉 FTS 索引表配置的同學不容易出錯,而且普通表的可擴充套件性好,支援新增新列;劣勢則是搜尋時需要先用 FTS 索引的 Rowid 讀取到普通表的 Rowid ,這樣才能讀取到普通表的其他內容,搜尋速度慢一點,而且搜尋時需要聯表查詢,搜尋 SQL 語句稍複雜。

第二種方式是將非文字搜尋內容直接和可搜尋文字內容一起儲存在 FTS 索引表中,表格式類似於這樣:

圖片

這種方式的優劣勢跟前一種方式恰好相反,優勢是搜尋速度快而且搜尋方式簡單,劣勢是擴充套件性差且需要更細緻的配置。

因為 IOS 微信以前是使用第二種表格式,而且微信的搜尋業務已經穩定不會有大變化,我們現在更加追求搜尋速度,所以使用第二種表格式來儲存全文搜尋的資料。

3.1.2 避免冗餘索引內容

FTS 索引表預設對錶中的每一列的內容都建倒排索引,即便是數字內容也會按照文字來處理,這樣會導致我們儲存在 FTS 索引表中的非文字搜尋內容也建了索引,進而增大索引檔案的大小、索引更新的耗時和搜尋的耗時,這顯然不是我們想要的。

FTS5 支援給索引表中的列新增 UNINDEXED 約束,這樣 FTS5 就不會對這個列建索引了,所以給可搜尋文字內容之外的所有列新增這個約束就可以避免冗餘索引。

3.1.3 降低索引內容的大小

前面提到,倒排索引主要儲存文字中每個 Token 對應的行號(rowid)、列號和欄位中的每次出現的位置偏移。其中的行號是 SQLite 自動分配的,位置偏移是根據業務的實際內容,這兩個我們都決定不了,但是列號是可以調整的。

在 FTS5 索引中,一個 Token 在一行中的索引內容的格式是這樣的:

圖片

從中可以看出,如果我們把可搜尋文字內容設定在第一列的話(多個可搜尋文字列的話,把內容多的列放到第一列),就可以少儲存列分割符 0x01 和列號,這樣可以明顯降低索引檔案大小。

所以我們最終的表格式是這樣:

圖片

3.1.4 索引檔案大小最佳化資料

下面是 IOS 微信最佳化前後,平均每個使用者的索引檔案大小對比:

圖片

3.2 索引更新邏輯最佳化

為了將全文搜尋邏輯和業務邏輯解耦,iOS 微信的 FTS 索引是不儲存在各個業務的資料庫中的,而是集中儲存到一個專用的全文搜尋資料庫,各個業務的資料有更新之後再非同步通知全文搜尋模組更新索引。整體流程如下:

圖片

這樣做既可以避免索引更新拖慢業務資料更新的速度,也能避免索引資料更新出錯甚至索引資料損壞對業務造成影響,讓全文搜尋功能模組能夠充分獨立。

3.2.1 保證索引和資料的一致

業務資料和索引資料分離且非同步同步的好處很多,但實現很難。最難的問題是如何保證業務資料和索引資料的一致,也即要保證業務資料和索引資料要逐條對應,不多不少。曾經 IOS 微信在這裡踩了很多坑,打了很多補丁都不能完整解決這個問題,我們需要一個更加體系化的方法來解決這個問題。

為了簡化問題,我們可以把一致性問題可以拆成兩個方面分別處理

  • 一個是保證所有業務資料都有索引,使用者的搜尋結果就不會有缺漏;
  • 二個是保證所有索引都對應一個有效的業務資料,這樣使用者就不會搜到無效的結果。

要保證所有業務資料都有索引,首先要找到或者構造一種一直增長的資料,來描述業務資料更新的進度。這個進度資料的更新和業務資料的更新能保證原子性,而且根據這個進度的區間能拿出業務資料更新的內容,這樣我們就可以依賴這個進度來更新索引。

在微信的業務中,不同業務的進度資料不同。聊天記錄是使用訊息的 rowid ,收藏是使用收藏跟後臺同步的 updateSequence ,而聯絡人找不到這種一直增長的進度資料,我們是透過在聯絡人資料庫中標記有新增或有更新的聯絡人的微訊號,來作為索引更新進度。進度資料的使用方法如下:

圖片

無論業務資料是否儲存成功、更新通知是否到達全文搜尋模組、索引資料是否儲存成功,這套索引更新邏輯都能保證儲存成功的業務資料都能成功建到索引。這其中的一個關鍵點是資料和進度要在同個事務中一起更新,而且要儲存在同個資料庫中,這樣才能保證資料和進度的更新的原子性( WCDB 建立的資料庫因為使用 WAL 模式而無法保證不同資料庫的事務的原子性)。還有一個操作具體是微信啟動時如果檢查到業務進度小於索引進度,這種一般意味著業務資料損壞後被重置了,這種情況下要刪掉索引並重置索引進度。

對於每個索引都對應有效的業務資料,這就要求業務資料刪除之後索引也要必須刪掉。現在業務資料的刪除和索引的刪除是非同步的,會出現業務資料刪掉之後索引沒刪除的情況。這種情況會導致兩個問題:

  • 一個是冗餘索引會導致搜尋速度變慢。這個問題出現機率很小,這個影響可以忽略不計;
  • 第二個問題是會導致使用者搜到無效資料。這個是要避免的。

要完全刪掉所有無效索引成本比較高,所以我們採用了惰性檢查的方法來解決這個問題。具體做法是搜尋結果要顯示給使用者時,才檢查這個資料是否有效,無效的話不顯示這個搜尋結果,並非同步刪除對應的索引。使用者一屏能看到的資料很少,所以檢查邏輯帶來的效能消耗,也可以忽略不計。而且這個檢查操作實際上也不算是額外加的邏輯,為了搜尋結果展示內容的靈活性,我們也要在展示搜尋結果時讀出業務資料,這樣也就順帶做了資料有效性的檢查。

圖片

3.2.2 建索引速度最佳化

索引只有在搜尋的時候才會用到,它的更新優先順序並沒有業務資料那麼高,可以儘量攢更多的業務資料才批次建索引。批次建索引有以下三個好處:

  • 第一,減少磁碟的寫入次數,提高平均建索引速度;
  • 第二,在一個事務中,建索引 SQL 語句的解析結果可以反覆使用,可以減少 SQL 語句的解析次數,進而提高平均建索引速度;
  • 第三,減少生成 segment 的數量,從而減少 merge segment 帶來的讀寫消耗。

當然,也不能保留太多業務資料不建索引,這樣使用者要搜尋時會來不及建索引,從而導致搜尋結果不完整。有了前面的 segment 自動 merge 機制,索引的寫入速度非常可控。只要控制好量,就不用擔心批次建索引帶來的高耗時問題。我們綜合考慮了低端機器的建索引速度和搜尋頁面的拉起時間,確定了最大批次建索引資料條數為 100 條。

同時,我們會在記憶體中 cache 本次微信執行期間產生的未建索引業務資料,在極端情況下給沒有來得及建索引的業務資料提供相對記憶體搜尋,保證搜尋結果的完整性。因為 cache 上一次微信執行期間產生的未建索引資料需要引入額外的磁碟 IO,所以微信啟動後會觸發一次建索引邏輯,對現有的未建索引業務資料建一次索引。總結一下觸發建索引的時機有三個:未建索引業務資料達到 100 條;進入搜尋介面;微信啟動。

3.2.3 刪除索引速度最佳化

索引的刪除速度經常是設計索引更新機制時比較容易忽視的因素,因為被刪除的業務資料量容易被低估,會被誤以為是低機率場景,但實際被使用者刪除的業務資料可能會達到 50%,是個不可忽視的主場景。

SQLite 是不支援並行寫入的,刪除索引的效能也會間接影響到索引的寫入速度,會為索引更新引入不可控因素。

因為刪除索引的時候是拿著業務資料的 id 去刪除的,所以提高刪除索引速度的方式有兩種:

  • 第一,建一個業務資料 id 到 FTS 索引的 rowid 的普通索引;
  • 第二,在 FTS 索引表中去掉業務資料id 那一列的 UNINDEXED 約束,給業務資料 id 新增倒排索引。

FTS 索引其實沒有普通索引那麼高效,有兩個原因:

  • 第一,FTS 索引相比普通索引還帶了很多額外資訊,搜尋效率低一些;
  • 第二,如果需要多個業務欄位才能確定一條 FTS 索引時,FTS 索引是建不了聯合索引的,只能匹配其中一個業務欄位,其他欄位就是遍歷匹配,這種情況搜尋效率會很低。

3.2.4 索引更新效能最佳化資料

聊天記錄的最佳化前後索引效能資料如下:

圖片

圖片

圖片

收藏的最佳化前後索引效能資料如下:

圖片

圖片

圖片

3.3 搜尋邏輯最佳化

使用者在 IOS 微信的首頁輸入內容搜尋時,搜尋的整體流程如下:

圖片

使用者變更搜尋框的內容之後,會並行發起所有業務的搜尋任務。各個搜尋任務執行完之後才再將搜尋結果返回到主執行緒給頁面展示。這個邏輯會隨著使用者變更搜尋內容而繼續重複。

3.3.1 單個搜尋任務支援並行執行

雖然現在不同搜尋任務已經支援並行執行,但是不同業務的資料量和搜尋邏輯差別很大。資料量大或者搜尋邏輯複雜的任務耗時會很久,這樣還不能充分發揮手機的並行處理能力。我們還可以將並行處理能力引入單個搜尋任務內,這裡有兩種處理方式

  • 第一,對於搜尋資料量大的業務(比如聊天記錄搜尋),可以將索引資料均分儲存到多個 FTS 索引表(注意這裡不均分的話還是會存在短板效應),這樣搜尋時可以並行搜尋各個索引表,然後彙總各個表的搜尋結果,再進行統一排序。這裡拆分的索引表數量既不能太多也不能太少,太多會超出手機實際的並行處理能力,也會影響其他搜尋任務的效能;太少又不能充分利用並行處理能力。以前微信用了十個 FTS 表儲存聊天記錄索引,現在改為使用四個 FTS 表。
  • 第二,對於搜尋邏輯複雜的業務(比如聯絡人搜尋),可以將可獨立執行的搜尋邏輯並行執行。比如在聯絡人搜尋任務中,我們將聯絡人的普通文字搜尋、拼音搜尋、標籤和地區的搜尋、多群成員的搜尋並行執行,搜完之後再合併結果進行排序。這裡為什麼不也用拆表的方式呢?因為這種搜尋結果數量少的場景,搜尋的耗時主要是集中在搜尋索引的環節。索引可以看做一顆 B 樹,將一顆 B 樹拆分成多個,搜尋耗時並不會成比例下降。

3.3.2 搜尋任務支援中斷

使用者在搜尋框持續輸入內容的過程中可能會自動多次發起搜尋任務。如果在前一次發起的搜尋任務還沒執行完時,就再次發起搜尋任務,那前後兩次搜尋任務就會互相影響對方效能。這種情況在使用者輸入內容從短到長的過程中容易出現。因為搜尋文字短的時候命中結果就很多,搜尋任務也就更加耗時,從而更有機會撞上後面的搜尋任務。太多工同時執行會容易引起手機發燙、爆記憶體的問題。所以我們需要讓搜尋任務支援隨時中斷,這樣就可以在後一次搜尋任務發起的時候,能夠中斷前一次的搜尋任務,避免任務量過多的問題。

搜尋任務支援中斷的實現方式是給每個搜尋任務設定一個 CancelFlag 。在搜尋邏輯執行時每搜到一個結果就判斷一下 CancelFlag 是否置位,如果置位了就立即退出任務。外部邏輯可以透過置位 CancelFlag 來中斷搜尋任務。邏輯流程如下圖所示:

圖片

為了讓搜尋任務能夠及時中斷,我們需要讓檢查 CancelFlag 的時間間隔儘量相等,要實現這個目標就要在搜尋時避免使用 OrderBy 子句對結果進行排序。因為 FTS5 不支援建立聯合索引,所以在使用 OrderBy 子句時,SQLite 在輸出第一個結果前會遍歷所有匹配結果進行排序,這就讓輸出第一個結果的耗時幾乎等於輸出全部結果的耗時,中斷邏輯就失去了意義。不使用OrderBy子句就對搜尋邏輯新增了兩個限制:

  • 第一,從資料庫讀取所有結果之後再排序。我們可以在讀取結果時將用於排序的欄位一併讀出,然後在讀完所有結果之後再對所有結果執行排序。因為排序的耗時佔總搜尋耗時的比例很低、排序演算法的效能大同小異,這種做法對搜尋速度的影響可以忽略。
  • 第二,不能使用分段查詢。在全文搜尋這個場景中,分段查詢其實沒有什麼作用。因為分段查詢就要對結果排序,對結果排序就要遍歷所有結果,所以分段查詢並不能降低搜尋耗時(除非按照 FTS 索引的 Rowid 分段查詢,但是 Rowid 不包含實際的業務資訊)。

3.3.3 搜尋讀取內容最少化

搜尋時讀取內容的量也是決定搜尋耗時的一個關鍵因素。FTS 索引表實際是多個 SQLite 普通表組成,這其中一些表格儲存實際的倒排索引內容,還有一個表格儲存使用者儲存到 FTS 索引表的全部原文。

當搜尋時讀取 Rowid 以外的內容時,就需要用 Rowid 到儲存原文的表的讀取內容,索引表輸出結果的內部執行過程如下:

圖片

所以讀取內容越少輸出結果的速度越快,而且讀取內容過多也會有消耗記憶體的隱患。我們採用的方式是搜尋時只讀取業務資料 id 和用於排序的業務屬性,排好序之後,在需要給使用者展示結果時,才用業務資料 id 按需讀取業務資料具體內容出來展示。這樣做的擴充套件性也會很好,可以在不更改儲存內容的情況下,根據各個業務的需求不斷調整搜尋結果展示的內容。

要特別注意的是:搜尋時儘量不要讀取高亮資訊(SQLite 的highlight函式有這個能力)。因為要獲取高亮欄位不僅要將文字的原文讀取出來,還要對文字原文再次分詞,才能定位命中位置的原文內容,搜尋結果多的情況下分詞帶來的消耗非常明顯。那展示搜尋結果時如何獲取高亮匹配內容呢?我們採用的方式是將使用者的搜尋文字進行分詞,在展示結果時查詢每個 Token 在展示文字中的位置,然後將那個位置高亮顯示。因為使用者一屏看到的結果數量是很少的,這裡的高亮邏輯帶來的效能消耗可以忽略。

當然,在搜尋規則很複雜的情況下,直接讀取高亮資訊是比較方便,比如聯絡人搜尋就使用前面提到的 SubstringMatchInfo 函式來讀取高亮內容。因為要讀取匹配內容所在的層級和位置用於排序,所以逐個結果重新分詞的操作在所難免。

3.3.4 搜尋效能最佳化資料

下面是微信各搜尋業務最佳化前後的搜尋耗時對比:

圖片

圖片

圖片

04、總結

目前 IOS 微信已經將這套新全文搜尋技術方案全量應用到聊天記錄、聯絡人和收藏的搜尋業務中。使用新方案之後,全文搜尋的索引檔案佔用空間更小,索引更新耗時更少、搜尋速度也更快,可以說全文搜尋的效能得到了全方位提升。

以上便是本次分享的全部內容,歡迎各位開發者在評論區交流討論。

-End-

原創作者|陳秋文

技術責編|陳秋文

你可能感興趣的騰訊工程師作品

騰訊工程師解讀ChatGPT技術「精選系列文集」

一文讀懂 Redis 架構演化之路

| 十問ChatGPT:一個新的時代正拉開序幕

7天DAU超億級,《羊了個羊》技術架構升級實戰

技術盲盒:前端後端AI與演算法運維|工程師文化

相關文章