純 MongoDB 實現中文全文搜索


本文來自獲得《2021MongoDB技術實踐與應用案例徵集活動》一等獎作品

摘要

MongoDB在2.4版中引入全文索引後幾經迭代更新已經比較完美地支持以空格分隔的西語,但一直不支持中日韓等語言,社區版用戶不得不通過掛接ElasticSearch等支持中文全文搜索的數據庫來實現業務需求,由此引入了許多業務限制、安全問題、性能問題和技術複雜性。作者獨闢蹊徑,基於純MongoDB社區版(v4.x和v5.0)實現中文全文搜索,在接近四千萬個記錄的商品表搜索商品名,檢索時間在200ms以內,並使用Change Streams技術同步數據變化,滿足了業務需要和用戶體驗需求。

本文首先描述遇到的業務需求和困難,介紹了MongoDB和Atlas Search對全文搜索的支持現狀,然後從全文搜索原理講起,結合MongoDB全文搜索實現,掛接中文分詞程序,達到純MongoDB社區版實現中文全文搜索的目標;針對性能需求,從分詞、組合文本索引、用戶體驗、實時性等多方面給出了優化實踐,使整個方案達到商業級的實用性。

業務需求和困難

電商易是作者公司的電商大數據工具品牌,旗下多個產品都有搜索商品的業務需求。早期的時候,我們的搜索是直接用$regex去匹配的,在數據量比較大的時候,需要耗時十幾秒甚至幾分鐘,所以用戶總是反饋說搜不出東西來。其實不是搜不出來,而是搜的時間太長,服務器掐斷連接了。加上我們普遍使用極簡風格的首頁,像搜索引擎那樣,有個框,右側是一個“一鍵分析”的按鈕,用戶點擊後顯示相關的商品的數據。搜索成爲用戶最常用的功能,搜索性能的問題也就變得更加突出了,優化搜索成爲了迫在眉睫的任務。

MongoDB在2.4版中引入文本索引(Text Index)實現了全文搜索(Full Text Search,下文簡稱FTS),雖然後來在2.6和3.2版本中兩經改版優化,但一直不支持中日韓等語言。MongoDB官網推出服務Atlas Search,也是通過外掛Lucene的方式支持的,這個服務需要付費,而且未在中國大陸地區運營,與我們無緣,所以還是要尋找自己的解決之道。

那麼能否僅僅基於MongoDB社區版實現中文全文搜索呢?帶着這個問題,作者深入到MongoDB文本索引的文檔、代碼中去,發現了些許端倪,並逐步實現和優化了純MongoDB實現中文全文搜索的方案,下文將從全文搜索的原理講起,詳細描述這個方案。

過程

全文搜索原理

倒排索引是搜索引警的基礎。倒排是與正排相對的,假設有一個 ID 爲 1 的文檔,內容爲“ My name is LaiYonghao.“,那麼通過 ID 1 總能找到這個文檔所有的詞。通過文檔 ID 找包含的詞,稱爲正排;反過來通過詞找到包括該詞的文檔 ID,稱爲倒排,詞與文檔ID的對應關係稱爲倒排索引。下面直接引用一下維基百科上的例子。

0 "it is what it is"
1 "what is it"
2 "it is a banana"

上面 3 個文檔的倒排索引大概如下:

"a": {2}
"banana": {2}
"is": {0, 1, 2}
"it": {0, 1, 2}
"what": {0, 1}

這時如果要搜索banana的話,利用倒排索引可以馬上查找到包括這個詞的文檔是ID爲2的文檔。而正排的話,只能一個一個文檔找過去,找完3個文檔才能找到(也就是$regex的方式),這種情況下的耗時大部分是無法接受的。

倒排索引是所有支持全文搜索的數據庫的基礎,無論是PostgreSQL還是MySQL都是用它來實現全文搜索的,MongoDB也不例外,這也是我們最終解決問題的基礎底座。簡單來說,倒排索引類似MongoDB裏的多鍵索引(Multikey Index),能夠通過內容元素找到對應的文檔。文本索引可以簡單類比爲對字符串分割(即分詞)轉換爲由詞組成的數組,並建立多鍵索引。雖然文本索引還是停止詞、同義詞、大小寫、權重和位置等信息需要處理,但大致如此理解是可以的。

西文的分詞較爲簡單,基本上是按空格分切即可,這就是MongoDB內置的默認分詞器:當建立文本索引時,默認分詞器將按空格分切句子。而CJK語言並不使用空格切分,而且最小單位是字,所以沒有辦法直接利用MongoDB的全文搜索。那麼如果我們預先將中文句子進行分詞,並用空格分隔重新組裝爲“句子”,不就可以利用上MongoDB的全文搜索功能了嗎?通過這一個突破點進行深挖,實驗證明,這是可行的,由此我們的問題就轉化爲了分詞問題。

一元分詞和二元分詞

從上文可知,數據庫的全文搜索是基於空格切分的詞作爲最小單位實現的。中文分詞的方法有很多,最基礎的是一元分詞和二元分詞。

所謂一元分詞:就是一個字一個字地切分,把字當成詞。如我愛北京天安門,可以切分爲我愛北京天安門,這是最簡單的分詞方法。這種方法帶來的問題就是文檔過於集中,常用漢字只有幾千個,姑且算作一萬個,如果有一千萬個文檔,每一個字會對應到10000000/10000*avg_len(doc)個。以文檔內容是電商平臺的商品名字爲例,平均長度約爲 60 個漢字,那每一個漢子對應 6 萬個文檔,用北京兩字搜索的話,要求兩個長度爲6萬的集合的交集,就會要很久的時間。所以大家更常使用二元分詞法。

所謂二元分詞:就是按兩字兩個分詞。如我愛北京天安門,分詞結果是我愛愛北北京京天天安安門。可見兩個字的組合數量多了很多,相對地一個詞對應的文檔也少了許多,當搜索兩個字的時候,如北京不用再求交集,可以直接得到結果。而搜索三個字以上的話,如天安門也是由天安和安門兩個不太常見的詞對應的文檔集合求交集,數量少,運算量也小,速度就很快。下面是純中文的二元分詞Python代碼,實際工作中需要考慮多語言混合的處理,在此僅作示例:

def bigram_tokenize(word):
  return' '.join(
    word[i:i+2]for i inrange(len(word))if i+2<=len(word)
  )

print(bigram_tokenize('我愛北京天安門'))
# 輸出結果:我愛 愛北 北京 京天 天安 安門

Lucene自帶一元分詞和二元分詞,它的中文全文搜索也是基於二元分詞和倒排索引實現的。接下來只需要預先把句子進行二元分詞再存入MongoDB,就可以藉助它已有的西語全文搜索功能實現對中文的搜索。

編寫索引程序

編寫一個分詞程序,它將全表遍歷需要實現全文搜索的集合(Collection),並將指定的文本字段內容進行分詞,存入指定的全文索引字段。

以對products表的name字段建立全文索引爲例,代碼大概如下:

def build_products_name_fts():
  # 在 _t 字段建立全文索引
 db.products.create_index([('_t', 'TEXT')])
  # 遍歷集合
  for prod in db.products.find({}):
   db.products.update_one(
      {'_id': prod['_id']},
      {
        '$set': {
          '_t': bigram_tokenize(prod['name'])  # 寫入二元分詞結果
        }
      }
    )

if__name__=="__main__":
   build_products_name_fts()

只需要10來行代碼就行了,它在首次運行的時候會做一次全表更新,完成後即可用以全文搜索。MongoDB的高級用戶也可以用帶更新的聚合管道完成這個功能,只需要寫針對二元分詞實現一個javascript函數(使用$function操作符)放到數據庫中執行即可。

查詢詞預處理

因爲我們針對二元分詞的結果做搜索,所以無法直接搜索。以牛仔褲爲例,二元分詞的全文索引里根本沒有三個字的詞,是搜索不出來結果的,必須轉換成短語"牛仔仔褲"這樣才能匹配上,所以要對查詢詞作預處理:進行二元分詞,並用雙引號約束位置,這樣才能正確查詢。

products = db.products.find(
    {
        '$text': {
            '$search': f'"{bigram_tokenize(kw)}"',
        }
    }
)

如果有多個查詢詞或帶有反向查詢詞,則需要作相應的處理,在此僅以獨詞查詢示例,具體不用細述。

MongoDB不僅支持在find中使用全文搜索,也可在aggregate中使用,在find中使用是差不多的,不過要留意的是隻能在第一階段使用帶$text的$match。

初步結果

首先值得肯定的是做了簡單的二元分詞處理之後,純MongoDB就能夠實現中文全文搜索,搜索結果是精準的,沒有錯搜或漏搜的情況。

不過在性能上比較差強人意,在約4000萬文檔的products集合中,搜索牛仔褲需要10秒鐘以上。而且在項目的使用場景中,我們發現用戶實際查詢的詞很長,往往是直接在電商平臺複製商品名的一部分,甚至全部,這種極端情況需要幾分鐘才能得到查詢結果。

在產品層面,可以對用戶查詢的詞長度進行限制,比如最多3個詞(即2個空格)且總長度不要超過10個漢字(或20個字母,每漢字按兩個字母計算),這樣可以控制相對快一點。但這樣的規則不容易讓用戶明白,用戶體驗受損,需要想辦法優化性能。

優化

結巴中文分詞

結巴中文分詞是最流行的Python中文分詞組件,它有一種搜索引擎模式,在精確模式的基礎上,對長詞再次切分,提高召回率,適合用於搜索引擎分詞。下面是引用自它項目主頁的示例:

seg_list = jieba.cut_for_search("小明碩士畢業於中國科學院計算所,後在日本京都大學深造")  # 搜索引擎模式
print(", ".join(seg_list))
# 結果:【搜索引擎模式】:小明, 碩士, 畢業, 於, 中國, 科學, 學院, 科學院, 中國科學院, 計算, 計算所, 後, 在, 日本, 京都, 大學, 日本京都大學, 深造

可見它的分詞數量比二元分詞少了很多,對應地索引產寸也小了。使用二元分詞時,4000萬文檔的products表索引超過40GB,而使用結巴分詞後,減少到約26GB。

由上例也可看出,結巴分詞的結果丟失了位置信息,所以查詢詞預處理過程也可以省略加入雙引號,這樣MongoDB在全文搜索時計算量也大大少,搜索速度加速了數十倍。以牛仔褲爲例,使用結巴分詞後查詢時間由10秒以上降到約400ms,而直接複製商品名進行長詞查詢,也基本上能夠在5秒鐘之內完成查詢,可用性和用戶體驗都得到了巨大提升。

結巴分詞的缺陷是需要行業詞典進行分詞。比如電商平臺的商品名都有長度限制,都是針對搜索引擎優化過的,日常用語“男裝牛仔褲”在電商平臺上被優化成了“牛仔褲男”,這顯然不是一個通常意義上的詞。在沒有行業詞典的情況下,結巴分詞的結果是牛仔褲男,用戶搜索時,將計算“牛仔褲”和“男”的結果交集;如果使用自定義詞典,將優化爲牛仔褲牛仔褲男,則無需計算,搜索速度更快,但增加了維護自定義詞典的成本。

組合全文索引(Compound textIndex)

組合全文索引是MongoDB的一個特色功能,是指帶有全文索引的組合索引。下面引用一個官方文檔的例子:

db.inventory.createIndex(
   {
     dept:1,
     description:"text"
   }
)
// 查詢
db.inventory.find( { dept:"kitchen",$text: { $search:"green" } } )

通過這種方式,當查詢部門(dept)字段的描述中是否有某些詞時,因爲先過濾掉了大量的非同dept的文檔,可以大大減少全文搜索的時間,從而實現性能優化。

儘管組合全文索引有許多限制,如查詢時必須指定前綴字段,且前綴字段只支持等值條件匹配等,但實際應用中還是有很多適用場景的,比如商品集合中有分類字段,天然就是等值條件匹配的,在此情況根據前綴字段的分散程度,基本上可以獲得同等比例的性能提升,一般都在10倍以上。

用戶體驗優化

MongoDB的全文搜索其實是很快的,但當需要根據其它字段進行排序的時候,就會顯著變慢。比如在我們的場景中,當搜索牛仔褲並按銷量排序時,速度顯著變慢。所以在產品設計時,應將搜索功能獨立,只解決“快速找出最想要的產品”這一個問題,想在一個功能裏解決多個問題,必然需要付出性能代價。

另一個有助於提升提升用戶體驗的技術手段是一次搜索,大量緩存。就是一個搜索詞第一次被查詢時,直接返回前面若干條結果,緩存起來(比如放到Redis),當用戶翻頁或其他用戶查詢此詞時,直接從緩存中讀取即可,速度大幅提升。

實時性優化

前文提到編寫索引程序對全文索引字段進行更新,但如果後面持續增加或修改數據時,也需要及時更新,否則實時性沒有保障。在此可以引入Change Streams,它允許應用程序訪問實時數據更改,而不必擔心跟蹤 oplog 的複雜性和風險。應用程序可以使用Change Streams來訂閱單個集合、數據庫或整個部署中的所有數據更改,並立即對它們作出反應。由於Change Streams使用聚合框架,應用程序還可以根據需要篩選特定的更改或轉換通知。Change Streams也是MongoDB Atlas Search同步數據變化的方法,所以它是非常可靠的。使用Change Streams非常簡單,我們的代碼片斷類似於這樣:

try:
    # 訂閱 products 集合的新增和修改Change Streams
    with db.products.watch(
            [{'$match': {'operationType': {'$in':['insert', 'update']}}}]) as stream:
        for insert_change in stream:
            check_name_changed_then_update(insert_change)
exceptpymongo.errors.PyMongoError:
    logging.error('...')

在check_name_changed_then_update()函數中我們檢查可搜索字段是否產生了變化(更新或刪除),如果是則對該文檔更新_t字段,從而實時數據更新。

總結

本文描述了作者實現純MongoDB實現中文全文搜索的過程,最終方案在生產環境中穩定運營了一年多時間,併爲多個產品採納,經受住了業務和時間的考驗,證明了方案的可行性和穩定性。在性能上在接近四千萬個記錄的商品表搜索商品名,檢索時間在200ms以內,並使用Change Streams技術同步數據變化,滿足了業務需要和用戶體驗需求。

作者在完成對中文全文搜索的探索過程中,經過對MongoDB源代碼的分析,發現mongo/src/mongo/db/fts目錄包含了對不同語言的分詞框架,在未來,作者將嘗試在MongoDB中實現中文分詞,期待用上內建中文全文搜索支持的那一天。

關於作者:賴勇浩

廣州天勤數據有限公司

2005年至2012年在網易(廣州)、廣州銀漢等公司從事網絡遊戲開發和技術管理工作。2013年至2014年在廣東彩惠帶領團隊從事彩票行業數字化研發和實施。2015年至今,創辦廣州齊昌網絡科技有限公司,後併入廣東天勤科技有限公司,任職CTO,並且擔任廣州天勤數據有限公司聯合創始人&CEO,現帶領團隊負責電商大數據分析軟件的研發工作,形成由看店寶等十餘個數據工具組成的產品矩陣,覆蓋分析淘寶、天貓、拼多多和抖音等多個電商平臺數據,服務全國各地200多萬電商從業人員。熱愛分享,於2009年聯合創辦程序員社區TechParty(原珠三角技術沙龍)並擔任兩屆組委主席,於2021年創辦中小團隊技術管理者和技術專家社區小紅花俱樂部,均深受目標羣體的喜愛。

精通Python、C++、Java等編程語言和Linux操作系統,熟悉大規模多人在線系統的設計與實現,在大數據方面,對數據收集、清洗、存儲、治理、分析等方面有豐富經驗,設計和實現了準PB級別的基於MongoDB的電商數據湖系統,對冷熱數據分級處理、系統成本控制和數據產品設計研發有一定心得。

曾在《計算機工程》等期刊發表多篇論文,於2014年出版《編寫高質量代碼:改善Python程序的91個建議》一書。

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