純 MongoDB 實(shí)現(xiàn)中文全文搜索
時(shí)間:2023-03-16 23:18:01 | 來源:電子商務(wù)
時(shí)間:2023-03-16 23:18:01 來源:電子商務(wù)
摘要
MongoDB在2.4版中引入全文索引后幾經(jīng)迭代更新已經(jīng)比較完美地支持以空格分隔的西語,但一直不支持中日韓等語言,社區(qū)版用戶不得不通過掛接ElasticSearch等支持中文全文搜索的數(shù)據(jù)庫來實(shí)現(xiàn)業(yè)務(wù)需求,由此引入了許多業(yè)務(wù)限制、安全問題、性能問題和技術(shù)復(fù)雜性。作者獨(dú)辟蹊徑,基于純MongoDB社區(qū)版(v4.x和v5.0)實(shí)現(xiàn)中文全文搜索,在接近四千萬個(gè)記錄的商品表搜索商品名,檢索時(shí)間在200ms以內(nèi),并使用Change Streams技術(shù)同步數(shù)據(jù)變化,滿足了業(yè)務(wù)需要和用戶體驗(yàn)需求。
本文首先描述遇到的業(yè)務(wù)需求和困難,介紹了MongoDB和Atlas Search對全文搜索的支持現(xiàn)狀,然后從全文搜索原理講起,結(jié)合MongoDB全文搜索實(shí)現(xiàn),掛接中文分詞程序,達(dá)到純MongoDB社區(qū)版實(shí)現(xiàn)中文全文搜索的目標(biāo);針對性能需求,從分詞、組合文本索引、用戶體驗(yàn)、實(shí)時(shí)性等多方面給出了優(yōu)化實(shí)踐,使整個(gè)方案達(dá)到商業(yè)級的實(shí)用性。
業(yè)務(wù)需求和困難
電商易是作者公司的電商大數(shù)據(jù)工具品牌,旗下多個(gè)產(chǎn)品都有搜索商品的業(yè)務(wù)需求。早期的時(shí)候,我們的搜索是直接用$regex去匹配的,在數(shù)據(jù)量比較大的時(shí)候,需要耗時(shí)十幾秒甚至幾分鐘,所以用戶總是反饋說搜不出東西來。其實(shí)不是搜不出來,而是搜的時(shí)間太長,服務(wù)器掐斷連接了。加上我們普遍使用極簡風(fēng)格的首頁,像搜索引擎那樣,有個(gè)框,右側(cè)是一個(gè)“一鍵分析”的按鈕,用戶點(diǎn)擊后顯示相關(guān)的商品的數(shù)據(jù)。搜索成為用戶最常用的功能,搜索性能的問題也就變得更加突出了,優(yōu)化搜索成為了迫在眉睫的任務(wù)。
MongoDB在2.4版中引入文本索引(Text Index)實(shí)現(xiàn)了全文搜索(Full Text Search,下文簡稱FTS),雖然后來在2.6和3.2版本中兩經(jīng)改版優(yōu)化,但一直不支持中日韓等語言。MongoDB官網(wǎng)推出服務(wù)Atlas Search,也是通過外掛Lucene的方式支持的,這個(gè)服務(wù)需要付費(fèi),而且未在中國大陸地區(qū)運(yùn)營,與我們無緣,所以還是要尋找自己的解決之道。
那么能否僅僅基于MongoDB社區(qū)版實(shí)現(xiàn)中文全文搜索呢?帶著這個(gè)問題,作者深入到MongoDB文本索引的文檔、代碼中去,發(fā)現(xiàn)了些許端倪,并逐步實(shí)現(xiàn)和優(yōu)化了純MongoDB實(shí)現(xiàn)中文全文搜索的方案,下文將從全文搜索的原理講起,詳細(xì)描述這個(gè)方案。
過程
全文搜索原理倒排索引是搜索引警的基礎(chǔ)。倒排是與正排相對的,假設(shè)有一個(gè) ID 為 1 的文檔,內(nèi)容為“ My name is LaiYonghao.“,那么通過 ID 1 總能找到這個(gè)文檔所有的詞。通過文檔 ID 找包含的詞,稱為正排;反過來通過詞找到包括該詞的文檔 ID,稱為倒排,詞與文檔ID的對應(yīng)關(guān)系稱為倒排索引。下面直接引用一下維基百科上的例子。
0 "it is what it is"
1 "what is it"
2 "it is a banana"
上面 3 個(gè)文檔的倒排索引大概如下:
"a": {2}
"banana": {2}
"is": {0, 1, 2}
"it": {0, 1, 2}
"what": {0, 1}
這時(shí)如果要搜索banana的話,利用倒排索引可以馬上查找到包括這個(gè)詞的文檔是ID為2的文檔。而正排的話,只能一個(gè)一個(gè)文檔找過去,找完3個(gè)文檔才能找到(也就是$regex的方式),這種情況下的耗時(shí)大部分是無法接受的。
倒排索引是所有支持全文搜索的數(shù)據(jù)庫的基礎(chǔ),無論是PostgreSQL還是MySQL都是用它來實(shí)現(xiàn)全文搜索的,MongoDB也不例外,這也是我們最終解決問題的基礎(chǔ)底座。簡單來說,倒排索引類似MongoDB里的多鍵索引(Multikey Index),能夠通過內(nèi)容元素找到對應(yīng)的文檔。文本索引可以簡單類比為對字符串分割(即分詞)轉(zhuǎn)換為由詞組成的數(shù)組,并建立多鍵索引。雖然文本索引還是停止詞、同義詞、大小寫、權(quán)重和位置等信息需要處理,但大致如此理解是可以的。
西文的分詞較為簡單,基本上是按空格分切即可,這就是MongoDB內(nèi)置的默認(rèn)分詞器:當(dāng)建立文本索引時(shí),默認(rèn)分詞器將按空格分切句子。而CJK語言并不使用空格切分,而且最小單位是字,所以沒有辦法直接利用MongoDB的全文搜索。那么如果我們預(yù)先將中文句子進(jìn)行分詞,并用空格分隔重新組裝為“句子”,不就可以利用上MongoDB的全文搜索功能了嗎?通過這一個(gè)突破點(diǎn)進(jìn)行深挖,實(shí)驗(yàn)證明,這是可行的,由此我們的問題就轉(zhuǎn)化為了分詞問題。
一元分詞和二元分詞
從上文可知,數(shù)據(jù)庫的全文搜索是基于空格切分的詞作為最小單位實(shí)現(xiàn)的。中文分詞的方法有很多,最基礎(chǔ)的是一元分詞和二元分詞。
所謂一元分詞:就是一個(gè)字一個(gè)字地切分,把字當(dāng)成詞。如我愛北京天安門,可以切分為我愛北京天安門,這是最簡單的分詞方法。這種方法帶來的問題就是文檔過于集中,常用漢字只有幾千個(gè),姑且算作一萬個(gè),如果有一千萬個(gè)文檔,每一個(gè)字會對應(yīng)到10000000/10000*avg_len(doc)個(gè)。以文檔內(nèi)容是電商平臺的商品名字為例,平均長度約為 60 個(gè)漢字,那每一個(gè)漢子對應(yīng) 6 萬個(gè)文檔,用北京兩字搜索的話,要求兩個(gè)長度為6萬的集合的交集,就會要很久的時(shí)間。所以大家更常使用二元分詞法。
所謂二元分詞:就是按兩字兩個(gè)分詞。如我愛北京天安門,分詞結(jié)果是我愛愛北北京京天天安安門??梢妰蓚€(gè)字的組合數(shù)量多了很多,相對地一個(gè)詞對應(yīng)的文檔也少了許多,當(dāng)搜索兩個(gè)字的時(shí)候,如北京不用再求交集,可以直接得到結(jié)果。而搜索三個(gè)字以上的話,如天安門也是由天安和安門兩個(gè)不太常見的詞對應(yīng)的文檔集合求交集,數(shù)量少,運(yùn)算量也小,速度就很快。下面是純中文的二元分詞Python代碼,實(shí)際工作中需要考慮多語言混合的處理,在此僅作示例:
def bigram_tokenize(word):
Lucene自帶一元分詞和二元分詞,它的中文全文搜索也是基于二元分詞和倒排索引實(shí)現(xiàn)的。接下來只需要預(yù)先把句子進(jìn)行二元分詞再存入MongoDB,就可以借助它已有的西語全文搜索功能實(shí)現(xiàn)對中文的搜索。
編寫索引程序
編寫一個(gè)分詞程序,它將全表遍歷需要實(shí)現(xiàn)全文搜索的集合(Collection),并將指定的文本字段內(nèi)容進(jìn)行分詞,存入指定的全文索引字段。
以對products表的name字段建立全文索引為例,代碼大概如下:
def build_products_name_fts():
只需要10來行代碼就行了,它在首次運(yùn)行的時(shí)候會做一次全表更新,完成后即可用以全文搜索。MongoDB的高級用戶也可以用帶更新的聚合管道完成這個(gè)功能,只需要寫針對二元分詞實(shí)現(xiàn)一個(gè)javascript函數(shù)(使用$function操作符)放到數(shù)據(jù)庫中執(zhí)行即可。
查詢詞預(yù)處理
因?yàn)槲覀冡槍Χ衷~的結(jié)果做搜索,所以無法直接搜索。以牛仔褲為例,二元分詞的全文索引里根本沒有三個(gè)字的詞,是搜索不出來結(jié)果的,必須轉(zhuǎn)換成短語"牛仔仔褲"這樣才能匹配上,所以要對查詢詞作預(yù)處理:進(jìn)行二元分詞,并用雙引號約束位置,這樣才能正確查詢。
products = db.products.find(
如果有多個(gè)查詢詞或帶有反向查詢詞,則需要作相應(yīng)的處理,在此僅以獨(dú)詞查詢示例,具體不用細(xì)述。
MongoDB不僅支持在find中使用全文搜索,也可在aggregate中使用,在find中使用是差不多的,不過要留意的是只能在第一階段使用帶$text的$match。
初步結(jié)果
首先值得肯定的是做了簡單的二元分詞處理之后,純MongoDB就能夠?qū)崿F(xiàn)中文全文搜索,搜索結(jié)果是精準(zhǔn)的,沒有錯搜或漏搜的情況。
不過在性能上比較差強(qiáng)人意,在約4000萬文檔的products集合中,搜索牛仔褲需要10秒鐘以上。而且在項(xiàng)目的使用場景中,我們發(fā)現(xiàn)用戶實(shí)際查詢的詞很長,往往是直接在電商平臺復(fù)制商品名的一部分,甚至全部,這種極端情況需要幾分鐘才能得到查詢結(jié)果。
在產(chǎn)品層面,可以對用戶查詢的詞長度進(jìn)行限制,比如最多3個(gè)詞(即2個(gè)空格)且總長度不要超過10個(gè)漢字(或20個(gè)字母,每漢字按兩個(gè)字母計(jì)算),這樣可以控制相對快一點(diǎn)。但這樣的規(guī)則不容易讓用戶明白,用戶體驗(yàn)受損,需要想辦法優(yōu)化性能。
優(yōu)化
結(jié)巴中文分詞結(jié)巴中文分詞是最流行的Python中文分詞組件,它有一種搜索引擎模式,在精確模式的基礎(chǔ)上,對長詞再次切分,提高召回率,適合用于搜索引擎分詞。下面是引用自它項(xiàng)目主頁的示例:
seg_list = jieba.cut_for_search("小明碩士畢業(yè)于中國科學(xué)院計(jì)算所,后在日本京都大學(xué)深造") # 搜索引擎模式
可見它的分詞數(shù)量比二元分詞少了很多,對應(yīng)地索引產(chǎn)寸也小了。使用二元分詞時(shí),4000萬文檔的products表索引超過40GB,而使用結(jié)巴分詞后,減少到約26GB。
由上例也可看出,結(jié)巴分詞的結(jié)果丟失了位置信息,所以查詢詞預(yù)處理過程也可以省略加入雙引號,這樣MongoDB在全文搜索時(shí)計(jì)算量也大大少,搜索速度加速了數(shù)十倍。以牛仔褲為例,使用結(jié)巴分詞后查詢時(shí)間由10秒以上降到約400ms,而直接復(fù)制商品名進(jìn)行長詞查詢,也基本上能夠在5秒鐘之內(nèi)完成查詢,可用性和用戶體驗(yàn)都得到了巨大提升。
結(jié)巴分詞的缺陷是需要行業(yè)詞典進(jìn)行分詞。比如電商平臺的商品名都有長度限制,都是針對搜索引擎優(yōu)化過的,日常用語“男裝牛仔褲”在電商平臺上被優(yōu)化成了“牛仔褲男”,這顯然不是一個(gè)通常意義上的詞。在沒有行業(yè)詞典的情況下,結(jié)巴分詞的結(jié)果是牛仔褲男,用戶搜索時(shí),將計(jì)算“牛仔褲”和“男”的結(jié)果交集;如果使用自定義詞典,將優(yōu)化為牛仔褲牛仔褲男,則無需計(jì)算,搜索速度更快,但增加了維護(hù)自定義詞典的成本。
組合全文索引(Compound textIndex)
組合全文索引是MongoDB的一個(gè)特色功能,是指帶有全文索引的組合索引。下面引用一個(gè)官方文檔的例子:
db.inventory.createIndex(
通過這種方式,當(dāng)查詢部門(dept)字段的描述中是否有某些詞時(shí),因?yàn)橄冗^濾掉了大量的非同dept的文檔,可以大大減少全文搜索的時(shí)間,從而實(shí)現(xiàn)性能優(yōu)化。
盡管組合全文索引有許多限制,如查詢時(shí)必須指定前綴字段,且前綴字段只支持等值條件匹配等,但實(shí)際應(yīng)用中還是有很多適用場景的,比如商品集合中有分類字段,天然就是等值條件匹配的,在此情況根據(jù)前綴字段的分散程度,基本上可以獲得同等比例的性能提升,一般都在10倍以上。
用戶體驗(yàn)優(yōu)化
MongoDB的全文搜索其實(shí)是很快的,但當(dāng)需要根據(jù)其它字段進(jìn)行排序的時(shí)候,就會顯著變慢。比如在我們的場景中,當(dāng)搜索牛仔褲并按銷量排序時(shí),速度顯著變慢。所以在產(chǎn)品設(shè)計(jì)時(shí),應(yīng)將搜索功能獨(dú)立,只解決“快速找出最想要的產(chǎn)品”這一個(gè)問題,想在一個(gè)功能里解決多個(gè)問題,必然需要付出性能代價(jià)。
另一個(gè)有助于提升提升用戶體驗(yàn)的技術(shù)手段是一次搜索,大量緩存。就是一個(gè)搜索詞第一次被查詢時(shí),直接返回前面若干條結(jié)果,緩存起來(比如放到Redis),當(dāng)用戶翻頁或其他用戶查詢此詞時(shí),直接從緩存中讀取即可,速度大幅提升。
實(shí)時(shí)性優(yōu)化
前文提到編寫索引程序?qū)θ乃饕侄芜M(jìn)行更新,但如果后面持續(xù)增加或修改數(shù)據(jù)時(shí),也需要及時(shí)更新,否則實(shí)時(shí)性沒有保障。在此可以引入Change Streams,它允許應(yīng)用程序訪問實(shí)時(shí)數(shù)據(jù)更改,而不必?fù)?dān)心跟蹤 oplog 的復(fù)雜性和風(fēng)險(xiǎn)。應(yīng)用程序可以使用Change Streams來訂閱單個(gè)集合、數(shù)據(jù)庫或整個(gè)部署中的所有數(shù)據(jù)更改,并立即對它們作出反應(yīng)。由于Change Streams使用聚合框架,應(yīng)用程序還可以根據(jù)需要篩選特定的更改或轉(zhuǎn)換通知。Change Streams也是MongoDB Atlas Search同步數(shù)據(jù)變化的方法,所以它是非常可靠的。使用Change Streams非常簡單,我們的代碼片斷類似于這樣:
try:
在check_name_changed_then_update()函數(shù)中我們檢查可搜索字段是否產(chǎn)生了變化(更新或刪除),如果是則對該文檔更新_t字段,從而實(shí)時(shí)數(shù)據(jù)更新。
優(yōu)化
本文描述了作者實(shí)現(xiàn)純MongoDB實(shí)現(xiàn)中文全文搜索的過程,最終方案在生產(chǎn)環(huán)境中穩(wěn)定運(yùn)營了一年多時(shí)間,并為多個(gè)產(chǎn)品采納,經(jīng)受住了業(yè)務(wù)和時(shí)間的考驗(yàn),證明了方案的可行性和穩(wěn)定性。在性能上在接近四千萬個(gè)記錄的商品表搜索商品名,檢索時(shí)間在200ms以內(nèi),并使用Change Streams技術(shù)同步數(shù)據(jù)變化,滿足了業(yè)務(wù)需要和用戶體驗(yàn)需求。
作者在完成對中文全文搜索的探索過程中,經(jīng)過對MongoDB源代碼的分析,發(fā)現(xiàn)mongo/src/mongo/db/fts目錄包含了對不同語言的分詞框架,在未來,作者將嘗試在MongoDB中實(shí)現(xiàn)中文分詞,期待用上內(nèi)建中文全文搜索支持的那一天。
關(guān)于作者:賴勇浩廣州天勤數(shù)據(jù)有限公司
2005年至2012年在網(wǎng)易(廣州)、廣州銀漢等公司從事網(wǎng)絡(luò)游戲開發(fā)和技術(shù)管理工作。2013年至2014年在廣東彩惠帶領(lǐng)團(tuán)隊(duì)從事彩票行業(yè)數(shù)字化研發(fā)和實(shí)施。2015年至今,創(chuàng)辦廣州齊昌網(wǎng)絡(luò)科技有限公司,后并入廣東天勤科技有限公司,任職CTO,并且擔(dān)任廣州天勤數(shù)據(jù)有限公司聯(lián)合創(chuàng)始人&CEO,現(xiàn)帶領(lǐng)團(tuán)隊(duì)負(fù)責(zé)電商大數(shù)據(jù)分析軟件的研發(fā)工作,形成由看店寶等十余個(gè)數(shù)據(jù)工具組成的產(chǎn)品矩陣,覆蓋分析淘寶、天貓、拼多多和抖音等多個(gè)電商平臺數(shù)據(jù),服務(wù)全國各地200多萬電商從業(yè)人員。熱愛分享,于2009年聯(lián)合創(chuàng)辦程序員社區(qū)TechParty(原珠三角技術(shù)沙龍)并擔(dān)任兩屆組委主席,于2021年創(chuàng)辦中小團(tuán)隊(duì)技術(shù)管理者和技術(shù)專家社區(qū)小紅花俱樂部,均深受目標(biāo)群體的喜愛。
精通Python、C++、Java等編程語言和Linux操作系統(tǒng),熟悉大規(guī)模多人在線系統(tǒng)的設(shè)計(jì)與實(shí)現(xiàn),在大數(shù)據(jù)方面,對數(shù)據(jù)收集、清洗、存儲、治理、分析等方面有豐富經(jīng)驗(yàn),設(shè)計(jì)和實(shí)現(xiàn)了準(zhǔn)PB級別的基于MongoDB的電商數(shù)據(jù)湖系統(tǒng),對冷熱數(shù)據(jù)分級處理、系統(tǒng)成本控制和數(shù)據(jù)產(chǎn)品設(shè)計(jì)研發(fā)有一定心得。
曾在《計(jì)算機(jī)工程》等期刊發(fā)表多篇論文,于2014年出版《編寫高質(zhì)量代碼:改善Python程序的91個(gè)建議》一書。
關(guān)鍵詞:中文,實(shí)現(xiàn)