以前幾段工做經歷都與搜索有關,如今也有業務在用搜索,對搜索引擎作一個原理性的分享,包括搜索的一系列核心數據結構和算法,儘可能覆蓋搜索引擎的核心原理,但不涉及數據挖掘、NLP等。文章有點長,多多指點~~算法
這裏有個概念須要提一下。信息檢索 (Information Retrieval 簡稱 IR) 和 搜索 (Search) 是有區別的,信息檢索是一門學科,研究信息的獲取、表示、存儲、組織和訪問,而搜索只是信息檢索的一個分支,其餘的如問答系統、信息抽取、信息過濾也能夠是信息檢索。數據庫
本文要講的搜索引擎,是一般意義上的全文搜索引擎、垂直搜索引擎的廣泛原理,好比 Google、Baidu,天貓搜索商品、口碑搜索美食、飛豬搜索酒店等。數組
Lucene 是很是出名且高效的全文檢索工具包,ES 和 Solr 底層都是使用的 Lucene,本文的大部分原理和算法都會以 Lucene 來舉例介紹。緩存
看一個實際的例子:如何從一個億級數據的商品表裏,尋找名字含「秋褲」的 商品。性能優化
select * from item where name like '%秋褲%'
如上,你們第一能想到的實現是用 like,但這沒法使用上索引,會在大量數據集上作一次遍歷操做,查詢會很是的慢。有沒有更簡單的方法呢,可能會說能不能加個秋褲的分類或者標籤,很好,那若是新增一個商品品類怎麼辦呢?要加無數個分類和標籤嗎?如何能更簡單高效的處理全文檢索呢?數據結構
答案是搜索,會事先 build 一個倒排索引,經過詞法語法分析、分詞、構建詞典、構建倒排表、壓縮優化等操做構建一個索引,查詢時經過詞典能快速拿到結果。這既能解決全文檢索的問題,又能解決了SQL查詢速度慢的問題。app
那麼,淘寶是如何在1毫秒從上億個商品找到上千種秋褲的呢,谷歌如何在1毫秒從萬億個網頁中找尋到與你關鍵字匹配的幾十萬個網頁,如此大的數據量是怎麼作到毫秒返回的。less
分詞就是對一段文本,經過規則或者算法分出多個詞,每一個詞做爲搜索的最細粒度一個個單字或者單詞。只有分詞後有這個詞,搜索才能搜到,分詞的正確性很是重要。分詞粒度太大,搜索召回率就會偏低,分詞粒度過小,準確率就會下降。如何恰到好處的分詞,是搜索引擎須要作的第一步。數據結構和算法
分詞正確性工具
分詞的粒度
分詞的粒度並非越小越好,他會下降準確率,好比搜索 「中秋」 也會出現上條結果,並且粒度越小,索引詞典越大,搜索效率也會降低,後面會細說。
如何準確的把控分詞,涉及到 NLP 的內容啦,這裏就不展開了。
不少語句中的詞都是沒有意義的,好比 「的」,「在」 等副詞、謂詞,英文中的 「a」,「an」,「the」,在搜索是無任何意義的,因此在分詞構建索引時都會去除,下降不不要的索引空間,叫停用詞 (StopWord)。
一般能夠經過文檔集頻率和維護停用詞表的方式來判斷停用詞。
詞項處理,是指在本來的詞項上在作一些額外的處理,好比歸一化、詞形歸併、詞幹還原等操做,以提升搜索的效果。並非全部的需求和業務都要詞項處理,須要根據場景來判斷。
1.歸一化
這樣查詢 U.S.A. 也能獲得 USA 的結果,同義詞能夠算做歸一化處理,不過同義詞還能夠有其餘的處理方式。
2.詞形歸併(Lemmatization)
針對英語同一個詞有不一樣的形態,能夠作詞形歸併成一個,如:
3.詞幹還原(Stemming)
一般指的就粗略的去除單詞兩端詞綴的啓發式過程
英文的常見詞幹還原算法,Porter算法。
要了解倒排索引,先看一下什麼是正排索引。好比有下面兩句話:
正排索引就是 MySQL 裏的 B+ Tree,索引的結果是:
表示對完整內容按字典序排序,獲得一個有序的列表,以加快檢索的速度。
第一步 分詞
第二步 將分詞項構建一個詞典
第三步 構建倒排鏈
由此,一個倒排索引就完成了,搜索 「檢索」 時,獲得 id1, id2,說明這兩條數據都有,搜索 「服務」 只有 id1 存在。但若是搜索 「檢索系統」,此時會先建搜索詞按照與構建同一種策略分詞,獲得 「檢索-系統」,兩個詞項,分別搜索 檢索 -> id1, id2 和 系統 -> id2,而後對其作一個交集,獲得 id2。同理,經過求並集能夠支持更復雜的查詢。
倒排索引到此也就講清楚了吧。
以 Lucene 爲例,簡單說明一下 Lucene 的存儲結構。從大到小是Index -> Segment -> Doc -> Field -> Term,類比 MySQL 爲 Database -> Table -> Record -> Field -> Value。
搜索結果排序是根據 關鍵字 和 Document 的相關性得分排序,一般意義下,除了能夠人工的設置權重 boost,也存在一套很是有用的相關性得分算法,看完你會以爲很是有意思。
TF(詞頻)-IDF(逆文檔頻率) 在自動提取文章關鍵詞上常常用到,經過它能夠知道某個關鍵字在這篇文檔裏的重要程度。其中 TF 表示某個 Term 在 Document 裏出現的頻次,越高說明越重要;DF 表示在所有 Document 裏,共有多少個 Document 出現了這個詞,DF 越大,說明這個詞很常見,並不重要,越小反而說明他越重要,IDF 是 DF 的倒數(取log), IDF 越大,表示這個詞越重要。
TF-IDF 怎麼影響搜索排序,舉一個實際例子來解釋:
假定如今有一篇博客《Blink 實戰總結》,咱們要統計這篇文章的關鍵字,首先是對文章分詞統計詞頻,出現次數最多的詞是--"的"、"是"、"在",這些是「停用詞」,基本上在全部的文章裏都會出現,他對找到結果毫無幫助,所有過濾掉。
只考慮剩下的有實際意義的詞,若是文章中詞頻數關係: 「Blink」 > 「詞頻」 = 「總結」,那麼確定是 Blink 是這篇文章更重要的關鍵字。但又會遇到了另外一個問題,若是發現 "Blink"、"實戰"、"總結"這三個詞的出現次數同樣多。這是否是意味着,做爲關鍵詞,它們的重要性是同樣的?
不是的,經過統計所有博客,你發現 含關鍵字總博客數: 「Blink」 < 「實戰」 < 「總結」,這時候說明 「Blink」 不怎麼常見,一旦出現,必定相比 「實戰」 和 「總結」,對這篇文章的重要性更大。
上面解釋了 TF 和 IDF,那麼 TF 和 IDF 誰更重要呢,怎麼計算最終的相關性得分呢?那就是 BM25。
BM25算法,一般用來做搜索相關性平分。一句話概況其主要思想:對Query進行語素解析,生成語素qi;而後,對於每一個搜索結果D,計算每一個語素qi與D的相關性得分,最後,將qi相對於D的相關性得分進行加權求和,從而獲得Query與D的相關性得分。
BM25算法的通常性公式以下:
其中,Q表示Query,qi表示Q解析以後的一個語素(對中文而言,咱們能夠把對Query的分詞做爲語素分析,每一個詞當作語素qi。);d表示一個搜索結果文檔;Wi表示語素qi的權重;R(qi,d)表示語素qi與文檔d的相關性得分。
其中 Wi 一般使用 IDF 來表達,R 使用 TF 來表達;綜上,BM25算法的相關性得分公式可總結爲:
BM25 經過使用不一樣的語素分析方法、語素權重斷定方法,以及語素與文檔的相關性斷定方法,咱們能夠衍生出不一樣的搜索相關性得分計算方法,這就爲咱們設計算法提供了較大的靈活性。
在點評口碑上,常常有相似的場景,搜索 「1千米之內的美食」,那麼這個1千米怎麼實現呢?
在數據庫中能夠經過暴力計算、矩形過濾、以及B樹對經度和維度建索引,但這性能仍然很慢。搜索裏用了一個很巧妙的方法,Geo Hash。
如上圖,表示根據 GeoHash 對北京幾個區域生成的字符串,有幾個特色:
地球上任何一個位置均可以用經緯度表示,緯度的區間是 [-90, 90],經度的區間 [-180, 180]。好比天安門的座標是 39.908,116.397,總體編碼過程以下:
1、對緯度 39.908 的編碼以下:
2、對經度 116.397 的編碼以下:
3、合併組碼
即最後天安門的4位 Geo Hash 爲 「WX4G」,若是須要經度更準確,在對應的經緯度編碼粒度再往下追溯便可。
附:Base32 編碼圖
舉個例子,搜索天安門附近 200 米的景點,以下是天安門附近的Geo編碼
搜索過程以下:
由上面步驟能夠看出,Geo Hash 將本來大量的距離計算,變成一個字符串檢索縮小範圍後,再進行小範圍的距離計算,及快速又準確的進行距離搜索。
如圖所示,咱們將二進制編碼的結果填寫到空間中,當將空間劃分爲四塊時候,編碼的順序分別是左下角00,左上角01,右下腳10,右上角11,也就是相似於Z的曲線。當咱們遞歸的將各個塊分解成更小的子塊時,編碼的順序是自類似的(分形),每個子快也造成Z曲線,這種類型的曲線被稱爲Peano空間填充曲線。
這種類型的空間填充曲線的優勢是將二維空間轉換成一維曲線(事實上是分形維),對大部分而言,編碼類似的距離也相近, 但Peano空間填充曲線最大的缺點就是突變性,有些編碼相鄰但距離卻相差很遠,好比0111與1000,編碼是相鄰的,但距離相差很大。
除Peano空間填充曲線外,還有不少空間填充曲線,如圖所示,其中效果公認較好是Hilbert空間填充曲線,相較於Peano曲線而言,Hilbert曲線沒有較大的突變。爲何GeoHash不選擇Hilbert空間填充曲線呢?多是Peano曲線思路以及計算上比較簡單吧,事實上,Peano曲線就是一種四叉樹線性編碼方式。
Lucene的倒排索引決定,索引內容是一個可排序的字符串,若是要查找一個數字,那麼也須要將數字轉成字符串。這樣,檢索一個數字是沒問題的,若是須要搜索一個數值範圍,怎麼作呢?
要作範圍查找,那麼要求數字轉成的字符串也是有序並單調的,但數字自己的位數是不同的,最簡單的版本就是前綴補0,好比 35, 234, 1 都補成 4 位,獲得 0035, 0234, 0001,這樣能保證:
數字(a) > 數字(b) ===> 字符串(a) > 字符串(b)
這時候,查詢應該用範圍內的全部數值或查詢,好比查詢 [33, 36) 這個範圍,對應的查詢語法是:
33 || 34 || 35
嗯看起來很好的解決了範圍查詢,可是,這樣存在3個問題:
故,涉及到範圍不能簡單的作字符串補位轉換,是否存在及節省空間,又能更高效解決問題的方案呢?
就是:
數值Trie樹,下面詳細介紹
上面說了怎麼索引,那麼Query呢?好比我給你一個Range Query從423-642,怎麼找到那6個term呢?
咱們首先能夠用shift==0找到範圍的起點後終點(有可能沒有相等的,好比搜索422,也會找到423)。而後一直往上找,直到找到一個共同的祖先(確定能找到,由於樹根是全部葉子節點的祖先),對應起點,每次往上走的時候, 左邊範圍節點都要把它右邊的兄弟節點都加進去, 右邊範圍節點都要把它左邊的兄弟節點加進去, 若已經到達頂點, 則是將左邊範圍節點和右邊範圍節點之間的節點加進行去
查找423到642之間的具體的區間:
另外還有一個問題,好比423會被分詞成423,42和4,那麼4也會被分詞成4,那麼4表示哪一個呢?
因此intToPrefixCoded方法會額外用一個char來保存shift:buffer[0] = (char)(SHIFT_START_INT + shift);
好比423分詞的4的shift是2(這裏是10進制的例子,二進制也是一樣的),423分紅423的shift是0,4的shift是0,所以前綴確定比後綴大。
最後,因爲索引在判斷時無需感知是不是數字,能夠把全部的數字當成二進制處理,這樣在存儲和效率上更高。
LSM (Log Structured Merge Tree),最先是谷歌的 「BigTable」 提出來的,目標是保證寫入性能,同時又能支持較高效率的檢索,在不少 NoSQL 中都有使用,Lucene 也是使用 LSM 思想來寫入。
普通的B+樹增長記錄可能須要執行 seek+update 操做,這須要大量磁盤尋道移動磁頭。而 LSM 採用記錄在文件末尾,順序寫入減小移動磁頭/尋道,執行效率高於 B+樹。具體 LSM 的原理是什麼呢?
爲了保持磁盤的IO效率,lucene避免對索引文件的直接修改,全部的索引文件一旦生成,就是隻讀,不能被改變的。其操做過程以下:
合併的過程:
Basic Compaction
每一個文件固定N個數量,超過N,則新建一個sstable;當sstable數大於M,則合併一個大sstable;當大sstable的數量大於M,則合併一個更大的sstable文件,依次類推。
可是,這會出現一個問題,就是大量的文件被建立,在最壞的狀況下,全部的文件都要搜索。
Levelled Compaction
像 LevelDB 和 Cassandra解決這個問題的方法是:實現了一個分層的,而不是根據文件大小來執行合併操做。
因此, LSM 是日誌和傳統的單文件索引(B+ tree,Hash Index)的中立,他提供一個機制來管理更小的獨立的索引文件(sstable)。
經過管理一組索引文件而不是單一的索引文件,LSM 將B+樹等結構昂貴的隨機IO變的更快,而代價就是讀操做要處理大量的索引文件(sstable)而不是一個,另外仍是一些IO被合併操做消耗。
Lucene的Segment設計思想,與LSM相似但又有些不一樣,繼承了LSM中數據寫入的優勢,可是在查詢上只能提供近實時而非實時查詢。
Segment在被flush或commit以前,數據保存在內存中,是不可被搜索的,這也就是爲何Lucene被稱爲提供近實時而非實時查詢的緣由。讀了它的代碼後,發現它並非不能實現數據寫入便可查,只是實現起來比較複雜。緣由是Lucene中數據搜索依賴構建的索引(例如倒排依賴Term Dictionary),Lucene中對數據索引的構建會在Segment flush時,而非實時構建,目的是爲了構建最高效索引。固然它可引入另一套索引機制,在數據實時寫入時即構建,但這套索引實現會與當前Segment內索引不一樣,須要引入額外的寫入時索引以及另一套查詢機制,有必定複雜度。
數據字典 Term Dictionary,一般要從數據字典找到指定的詞的方法是,將全部詞排序,用二分查找便可。這種方式的時間複雜度是 Log(N),佔用空間大小是 O(N*len(term))。缺點是消耗內存,存在完整的term,當 term 數達到上千萬時,佔用內存很是大。
lucene從4開始大量使用的數據結構是FST(Finite State Transducer)。FST有兩個優勢:
那麼 FST 數據結構是什麼原理呢? 先來看看什麼是 FSM (Finite State Machine), 有限狀態機,從「起始狀態」到「終止狀態」,可接受一個字符後,自循環或轉移到下一個狀態。
而FST呢,就是一種特殊的 FSM,在 Lucene 中用來實現字典查找功能(NLP中還能夠作轉換功能),FST 能夠表示成FST的形式
舉例:對「cat」、 「deep」、 「do」、 「dog」 、「dogs」 這5個單詞構建FST(注:必須已排序),結構以下:
當存在 value 爲對應的 docId 時,如 cat/0 deep/1 do/2 dog/3 dogs/4, FST 結構圖以下:
FST 還有一個特色,就是在前綴公用的基礎上,還會作一個後綴公用,目標一樣是爲了壓縮存儲空間。
其中紅色的弧線表 NEXT-optimized,能夠經過 畫圖工具 來測試。
爲了可以快速查找docid,lucene採用了SkipList這一數據結構。SkipList有如下幾個特徵:
在什麼位置設置跳錶指針?
• 設置較多的指針,較短的步長, 更多的跳躍機會
• 更多的指針比較次數和更多的存儲空間
• 設置較少的指針,較少的指針比較次數,可是須要設置較長的步長較少的連續跳躍
若是倒排表的長度是L,那麼在每隔一個步長S處均勻放置跳錶指針。
也叫 Block KD-tree,根據FST思路,若是查詢條件很是多,須要對每一個條件根據 FST 查出結果,進行求並集操做。若是是數值類型,那麼潛在的 Term 可能很是多,查詢銷量也會很低,爲了支持高效的數值類或者多維度查詢,引入 BKD Tree。在一維下就是一棵二叉搜索樹,在二維下是若是要查詢一個區間,logN的複雜度就能夠訪問到葉子節點對應的倒排鏈。
二進制處理,經過BKD-Tree查找到的docID是無序的,因此要麼先轉成有序的docID數組,或者構造BitSet,而後再與其餘結果合併。
IndexSorting是一種預排序,在ES6.0以後纔有,與查詢時的Sort不一樣,IndexSorting是一種預排序,即數據預先按照某種方式進行排序,它是Index的一個設置,不可更改。
一個Segment中的每一個文檔,都會被分配一個docID,docID從0開始,順序分配。在沒有IndexSorting時,docID是按照文檔寫入的順序進行分配的,在設置了IndexSorting以後,docID的順序就與IndexSorting的順序一致。
舉個例子來講,假如文檔中有一列爲Timestamp,咱們在IndexSorting中設置按照Timestamp逆序排序,那麼在一個Segment內,docID越小,對應的文檔的Timestamp越大,即按照Timestamp從大到小的順序分配docID。
IndexSorting 之因此能夠優化性能,是由於能夠提早中斷以及提升數據壓縮率,可是他並不能知足全部的場景,好比使用非預排序字段排序,還會損耗寫入時的性能。
搜索引擎正是靠優秀的理論加極致的優化,作到查詢性能上的極致,後續會再結合源碼分析壓縮算法如何作到極致的性能優化的。
未完待續~
原文連接 本文爲雲棲社區原創內容,未經容許不得轉載。