面向對象(下):如何實現一個搜索引擎-day10

面向對象(下):如何實現一個搜索引擎

寫在前面

你好,我是禪墨!

承接上文,今天的主要目的是,模擬敏捷開發過程中的迭代開發流程,鞏固面向對象的程序設計思想。

我們將從最簡單最直接的搜索做起,一步步優化,這其中,我不會涉及到過多的超綱算法,但不可避免會介紹一些現代搜索引擎中的基礎概念,例如語料(corpus)、倒序索引(inverted index)等。

如果你對這方面本身有些瞭解,自然可以輕鬆理解;即使你之前完全沒接觸過搜索引擎,也不用過分擔心,我會力求簡潔清晰,降低學習難度。同時,我希望你把更多的精力放在面向對象的建模思路上。

"高大上"的搜索引擎

引擎一詞尤如其名,聽起來非常酷炫。搜索引擎,則是新世紀初期互聯網發展最重要的入口之一,依託搜索引擎,中國和美國分別誕生了百度、谷歌等巨型公司。

搜索引擎極大地方便了互聯網生活,也成爲上網必不可少的剛需工具。依託搜索引擎發展起來的互聯網廣告,則成了硅谷和中國巨頭的核心商業模式;而搜索本身,也在持續進步着, Facebook 和微信也一直有意向在自家社交產品架設搜索平臺。關於搜索引擎的價值我不必多說了,今天我們主要來看一下搜索引擎的核心構成。

Google 的入職培訓的,有一門課程叫做 The life of a query,內容是講用戶在瀏覽器中鍵入一串文字,按下回車後發生了什麼。

今天我也嘗試按照這個思路,來簡單介紹下。

我們知道,一個搜索引擎由搜索器、索引器、檢索器和用戶接口四個部分組成

搜索器,通俗來講就是我們常提到的爬蟲(scrawler),它能在互聯網上大量爬取各類網站的內容,送給索引器。索引器拿到網頁和內容後,會對內容進行處理,形成索引(index),存儲於內部的數據庫等待檢索。

最後的用戶接口很好理解,是指網頁和 App 前端界面,例如百度和谷歌的搜索頁面。用戶通過用戶接口,向搜索引擎發出詢問(query),詢問解析後送達檢索器;檢索器高效檢索後,再將結果返回給用戶。

爬蟲知識不是我們今天的重點,這裏我就不做深入介紹了。我們假設搜索樣本存在於本地磁盤上。

爲了方便,我們只提供五個文件的檢索,內容我放在了下面這段代碼中:


# 1.txt
I have a dream that my four little children will one day live in a nation where they will not be judged by the color of their skin but by the content of their character. I have a dream today.

# 2.txt
I have a dream that one day down in Alabama, with its vicious racists, . . . one day right there in Alabama little black boys and black girls will be able to join hands with little white boys and white girls as sisters and brothers. I have a dream today.

# 3.txt
I have a dream that one day every valley shall be exalted, every hill and mountain shall be made low, the rough places will be made plain, and the crooked places will be made straight, and the glory of the Lord shall be revealed, and all flesh shall see it together.

# 4.txt
This is our hope. . . With this faith we will be able to hew out of the mountain of despair a stone of hope. With this faith we will be able to transform the jangling discords of our nation into a beautiful symphony of brotherhood. With this faith we will be able to work together, to pray together, to struggle together, to go to jail together, to stand up for freedom together, knowing that we will be free one day. . . .

# 5.txt
And when this happens, and when we allow freedom ring, when we let it ring from every village and every hamlet, from every state and every city, we will be able to speed up that day when all of God's children, black men and white men, Jews and Gentiles, Protestants and Catholics, will be able to join hands and sing in the words of the old Negro spiritual: "Free at last! Free at last! Thank God Almighty, we are free at last!"

我們先來定義 SearchEngineBase 基類。這裏我先給出了具體的代碼,你不必着急操作,還是那句話,跟着節奏慢慢學,再難的東西也可以啃得下來。


class SearchEngineBase(object):
    def __init__(self):
        pass

    def add_corpus(self, file_path):
        with open(file_path, 'r') as fin:
            text = fin.read()
        self.process_corpus(file_path, text)

    def process_corpus(self, id, text):
        raise Exception('process_corpus not implemented.')

    def search(self, query):
        raise Exception('search not implemented.')
py
def main(search_engine):
    for file_path in ['1.txt', '2.txt', '3.txt', '4.txt', '5.txt']:
        search_engine.add_corpus(file_path)

    while True:
        query = input()
        results = search_engine.search(query)
        print('found {} result(s):'.format(len(results)))
        for result in results:
            print(result)

SearchEngineBase 可以被繼承,繼承的類分別代表不同的算法引擎。每一個引擎都應該實現 process_corpus() 和 search() 兩個函數,對應我們剛剛提到的索引器和檢索器。main() 函數提供搜索器和用戶接口,於是一個簡單的包裝界面就有了。

具體來看這段代碼,其中,

  • add_corpus() 函數負責讀取文件內容,將文件路徑作爲 ID,連同內容一起送到 process_corpus 中。
  • process_corpus 需要對內容進行處理,然後文件路徑爲 ID ,將處理後的內容存下來。處理後的內容,就叫做索引(index)。
  • search 則給定一個詢問,處理詢問,再通過索引檢索,然後返回。

好,理解這些概念後,接下來,我們實現一個最基本的可以工作的搜索引擎,代碼如下:


class SimpleEngine(SearchEngineBase):
    def __init__(self):
        super(SimpleEngine, self).__init__()
        self.__id_to_texts = {}

    def process_corpus(self, id, text):
        self.__id_to_texts[id] = text

    def search(self, query):
        results = []
        for id, text in self.__id_to_texts.items():
            if query in text:
                results.append(id)
        return results

search_engine = SimpleEngine()
main(search_engine)


########## 輸出 ##########


simple
found 0 result(s):
little
found 2 result(s):
1.txt
2.txt

你可能很驚訝,只需要短短十來行代碼居然就可以了嗎?

沒錯,正是如此,這段代碼我們拆開來看一下:

SimpleEngine 實現了一個繼承 SearchEngineBase 的子類,繼承並實現了 process_corpus 和 search 接口,同時,也順手繼承了 add_corpus 函數(當然你想重寫也是可行的),因此我們可以在 main() 函數中直接調取。

在我們新的構造函數中,self.__id_to_texts = {} 初始化了自己的私有變量,也就是這個用來存儲文件名到文件內容的字典。

process_corpus() 函數則非常直白地將文件內容插入到字典中。這裏注意,ID 需要是唯一的,不然相同 ID 的新內容會覆蓋掉舊的內容。

search 直接枚舉字典,從中找到要搜索的字符串。如果能夠找到,則將 ID 放到結果列表中,最後返回。

你看,是不是非常簡單呢?這個過程始終貫穿着面向對象的思想,這裏我爲你梳理成了幾個問題,你可以自己思考一下,

  • 現在你對父類子類的構造函數調用順序和方法應該更清楚了吧?
  • 集成的時候,函數是如何重寫的?
  • 基類是如何充當接口作用的(你可以自行刪掉子類中的重寫函數,抑或是修改一下函數的參數,看一下會報什麼錯)?
  • 方法和變量之間又如何銜接起來的呢?

好的,我們重新回到搜索引擎這個話題。

相信你也能看得出來,這種實現方式簡單,但顯然是一種很低效的方式:每次索引後需要佔用大量空間,因爲索引函數並沒有做任何事情;每次檢索需要佔用大量時間,因爲所有索引庫的文件都要被重新搜索一遍。如果把語料的信息量視爲 n,那麼這裏的時間複雜度和空間複雜度都應該是 O(n) 級別的。

而且,還有一個問題:這裏的 query 只能是一個詞,或者是連起來的幾個詞。如果你想要搜索多個詞,它們又分散在文章的不同位置,我們的簡單引擎就無能爲力了。

這時應該怎麼優化呢?

最直接的一個想法,就是把語料分詞,看成一個個的詞彙,這樣就只需要對每篇文章存儲它所有詞彙的 set 即可。根據齊夫定律(Zipf’s law,https://en.wikipedia.org/wiki/Zipf%27s_law),在自然語言的語料庫裏,一個單詞出現的頻率與它在頻率表裏的排名成反比,呈現冪律分佈。因此,語料分詞的做法可以大大提升我們的存儲和搜索效率。

那具體該如何實現呢?

Bag of Words 和 Inverted Index

我們先來實現一個名叫 Bag of Words 的搜索模型。請看下面的代碼:


import re

class BOWEngine(SearchEngineBase):
    def __init__(self):
        super(BOWEngine, self).__init__()
        self.__id_to_words = {}

    def process_corpus(self, id, text):
        self.__id_to_words[id] = self.parse_text_to_words(text)

    def search(self, query):
        query_words = self.parse_text_to_words(query)
        results = []
        for id, words in self.__id_to_words.items():
            if self.query_match(query_words, words):
                results.append(id)
        return results
    
    @staticmethod
    def query_match(query_words, words):
        for query_word in query_words:
            if query_word not in words:
                return False
        return True

    @staticmethod
    def parse_text_to_words(text):
        # 使用正則表達式去除標點符號和換行符
        text = re.sub(r'[^\w ]', ' ', text)
        # 轉爲小寫
        text = text.lower()
        # 生成所有單詞的列表
        word_list = text.split(' ')
        # 去除空白單詞
        word_list = filter(None, word_list)
        # 返回單詞的 set
        return set(word_list)

search_engine = BOWEngine()
main(search_engine)


########## 輸出 ##########


i have a dream
found 3 result(s):
1.txt
2.txt
3.txt
freedom children
found 1 result(s):
5.txt

你應該發現,代碼開始變得稍微複雜些了。

這裏我們先來理解一個概念,BOW Model,即 Bag of Words Model,中文叫做詞袋模型。這是 NLP 領域最常見最簡單的模型之一。

假設一個文本,不考慮語法、句法、段落,也不考慮詞彙出現的順序,只將這個文本看成這些詞彙的集合。於是相應的,我們把 id_to_texts 替換成 id_to_words,這樣就只需要存這些單詞,而不是全部文章,也不需要考慮順序。

其中,process_corpus() 函數調用類靜態函數 parse_text_to_words,將文章打碎形成詞袋,放入 set 之後再放到字典中。

search() 函數則稍微複雜一些。這裏我們假設,想得到的結果,是所有的搜索關鍵詞都要出現在同一篇文章中。那麼,我們需要同樣打碎 query 得到一個 set,然後把 set 中的每一個詞,和我們的索引中每一篇文章進行覈對,看一下要找的詞是否在其中。而這個過程由靜態函數 query_match 負責。

你可以回顧一下學到的靜態函數,我們看到,這兩個函數都是沒有狀態的,它們不涉及對象的私有變量(沒有 self 作爲參數),相同的輸入能夠得到完全相同的輸出結果。因此設置爲靜態,可以方便其他的類來使用。

可是,即使這樣做,每次查詢時依然需要遍歷所有 ID,雖然比起 Simple 模型已經節約了大量時間,但是互聯網上有上億個頁面,每次都全部遍歷的代價還是太大了。到這時,又該如何優化呢?

你可能想到了,我們每次查詢的 query 的單詞量不會很多,一般也就幾個、最多十幾個的樣子。那可不可以從這裏下手呢?

再有,詞袋模型並不考慮單詞間的順序,但有些人希望單詞按順序出現,或者希望搜索的單詞在文中離得近一些,這種情況下詞袋模型現任就無能爲力了。

針對這兩點,我們還能做得更好嗎?顯然是可以的,請看接下來的這段代碼。


import re

class BOWInvertedIndexEngine(SearchEngineBase):
    def __init__(self):
        super(BOWInvertedIndexEngine, self).__init__()
        self.inverted_index = {}

    def process_corpus(self, id, text):
        words = self.parse_text_to_words(text)
        for word in words:
            if word not in self.inverted_index:
                self.inverted_index[word] = []
            self.inverted_index[word].append(id)

    def search(self, query):
        query_words = list(self.parse_text_to_words(query))
        query_words_index = list()
        for query_word in query_words:
            query_words_index.append(0)
        
        # 如果某一個查詢單詞的倒序索引爲空,我們就立刻返回
        for query_word in query_words:
            if query_word not in self.inverted_index:
                return []
        
        result = []
        while True:
            
            # 首先,獲得當前狀態下所有倒序索引的 index
            current_ids = []
            
            for idx, query_word in enumerate(query_words):
                current_index = query_words_index[idx]
                current_inverted_list = self.inverted_index[query_word]
                
                # 已經遍歷到了某一個倒序索引的末尾,結束 search
                if current_index >= len(current_inverted_list):
                    return result

                current_ids.append(current_inverted_list[current_index])

            # 然後,如果 current_ids 的所有元素都一樣,那麼表明這個單詞在這個元素對應的文檔中都出現了
            if all(x == current_ids[0] for x in current_ids):
                result.append(current_ids[0])
                query_words_index = [x + 1 for x in query_words_index]
                continue
            
            # 如果不是,我們就把最小的元素加一
            min_val = min(current_ids)
            min_val_pos = current_ids.index(min_val)
            query_words_index[min_val_pos] += 1

    @staticmethod
    def parse_text_to_words(text):
        # 使用正則表達式去除標點符號和換行符
        text = re.sub(r'[^\w ]', ' ', text)
        # 轉爲小寫
        text = text.lower()
        # 生成所有單詞的列表
        word_list = text.split(' ')
        # 去除空白單詞
        word_list = filter(None, word_list)
        # 返回單詞的 set
        return set(word_list)

search_engine = BOWInvertedIndexEngine()
main(search_engine)


########## 輸出 ##########


little
found 2 result(s):
1.txt
2.txt
little vicious
found 1 result(s):
2.txt

首先我要強調一下,這次的算法並不需要你完全理解,這裏的實現有一些超出了本章知識點。但希望你不要因此退縮,這個例子會告訴你,面向對象編程是如何把算法複雜性隔離開來,而保留接口和其他的代碼不變。

我們接着來看這段代碼。你可以看到,新模型繼續使用之前的接口,仍然只在 —init()、process_corpus()和search()三個函數進行修改。

這其實也是大公司裏團隊協作的一種方式,在合理的分層設計後,每一層的邏輯只需要處理好分內的事情即可。在迭代升級我們的搜索引擎內核時, main 函數、用戶接口沒有任何改變。當然,如果公司招了新的前端工程師,要對用戶接口部分進行修改,新人也不需要過分擔心後臺的事情,只要做好數據交互就可以了。

繼續看代碼,你可能注意到了開頭的 Inverted Index。Inverted Index Model,即倒序索引,是非常有名的搜索引擎方法,接下來我簡單介紹一下。

倒序索引,一如其名,也就是說這次反過來,我們保留的是 word -> id 的字典。於是情況就豁然開朗了,在 search 時,我們只需要把想要的 query_word 的幾個倒序索引單獨拎出來,然後從這幾個列表中找共有的元素,那些共有的元素,即 ID,就是我們想要的查詢結果。這樣,我們就避免了將所有的 index 過一遍的尷尬。

process_corpus 建立倒序索引。注意,這裏的代碼都是非常精簡的。在工業界領域,需要一個 unique ID 生成器,來對每一篇文章標記上不同的 ID,倒序索引也應該按照這個 unique_id 來進行排序。

至於 search() 函數,你大概瞭解它做的事情即可。它會根據 query_words 拿到所有的倒序索引,如果拿不到,就表示有的 query word 不存在於任何文章中,直接返回空;拿到之後,運行一個“合併 K 個有序數組”的算法,從中拿到我們想要的 ID,並返回

注意,這裏用到的算法並不是最優的,最優的寫法需要用最小堆來存儲 index。這是一道有名的 leetcode hard 題,有興趣請參考:https://blog.csdn.net/qqxx6661/article/details/77814794)

遍歷的問題解決了,那第二個問題,如果我們想要實現搜索單詞按順序出現,或者希望搜索的單詞在文中離得近一些呢?

我們需要在 Inverted Index 上,對於每篇文章也保留單詞的位置信息,這樣一來,在合併操作的時候處理一下就可以了。

倒序索引我就介紹到這裏了,如果你感興趣可以自行查閱資料。還是那句話,我們的重點是面向對象的抽象,別忘了體會這一思想。

LRU 和多重繼承

到這一步,終於,你的搜索引擎上線了,有了越來越多的訪問量(QPS)。欣喜驕傲的同時,你卻發現服務器有些“不堪重負”了。經過一段時間的調研,你發現大量重複性搜索佔據了 90% 以上的流量,於是,你想到了一個大殺器——給搜索引擎加一個緩存。

所以,最後這部分,我就來講講緩存和多重繼承的內容。


import pylru

class LRUCache(object):
    def __init__(self, size=32):
        self.cache = pylru.lrucache(size)
    
    def has(self, key):
        return key in self.cache
    
    def get(self, key):
        return self.cache[key]
    
    def set(self, key, value):
        self.cache[key] = value

class BOWInvertedIndexEngineWithCache(BOWInvertedIndexEngine, LRUCache):
    def __init__(self):
        super(BOWInvertedIndexEngineWithCache, self).__init__()
        LRUCache.__init__(self)
    
    def search(self, query):
        if self.has(query):
            print('cache hit!')
            return self.get(query)
        
        result = super(BOWInvertedIndexEngineWithCache, self).search(query)
        self.set(query, result)
        
        return result

search_engine = BOWInvertedIndexEngineWithCache()
main(search_engine)


########## 輸出 ##########


little
found 2 result(s):
1.txt
2.txt
little
cache hit!
found 2 result(s):
1.txt
2.txt

它的代碼很簡單,LRUCache 定義了一個緩存類,你可以通過繼承這個類來調用其方法。LRU 緩存是一種很經典的緩存(同時,LRU 的實現也是硅谷大廠常考的算法面試題,這裏爲了簡單,我直接使用 pylru 這個包),它符合自然界的局部性原理,可以保留最近使用過的對象,而逐漸淘汰掉很久沒有被用過的對象。

因此,這裏的緩存使用起來也很簡單,調用 has() 函數判斷是否在緩存中,如果在,調用 get 函數直接返回結果;如果不在,送入後臺計算結果,然後再塞入緩存。

我們可以看到,BOWInvertedIndexEngineWithCache 類,多重繼承了兩個類。首先,你需要注意的是構造函數(上節課的思考題,你思考了嗎?)。多重繼承有兩種初始化方法,我們分別來看一下。

第一種方法,用下面這行代碼,直接初始化該類的第一個父類:

super(BOWInvertedIndexEngineWithCache, self).init()

不過使用這種方法時,要求繼承鏈的最頂層父類必須要繼承 object。

第二種方法,對於多重繼承,如果有多個構造函數需要調用, 我們必須用傳統的方法LRUCache.init(self) 。

其次,你應該注意,search() 函數被子類 BOWInvertedIndexEngineWithCache 再次重載,但是我還需要調用 BOWInvertedIndexEngine 的 search() 函數,這時該怎麼辦呢?請看下面這行代碼:

super(BOWInvertedIndexEngineWithCache, self).search(query)

我們可以強行調用被覆蓋的父類的函數。

這樣一來,我們就簡潔地實現了緩存,而且還是在不影響 BOWInvertedIndexEngine 代碼的情況下。這部分內容希望你多讀幾遍,自己揣摩清楚,通過這個例子多多體會繼承的優勢。

總結

本篇文章基於景霄老師的指導與文案

今天是面向對象的實戰應用,相比起前面的理論知識,內容其實不那麼友好。不過,若你能靜下心來,仔細學習,理清楚整個過程的要點,對你理解面向對象必將有所裨益。比如,你可以根據下面兩個問題,來檢驗今天這節課的收穫。

  • 你能把這節課所有的類的屬性和函數抽取出來,自己在紙上畫一遍繼承關係嗎?
  • 迭代開發流程是怎樣的?

其實於我而言,通過構造搜索引擎這麼一個例子來講、搞面向對象,也是頗費了一番功夫。這其中雖然涉及一些搜索引擎的專業知識和算法,但篇幅有限,也只能算是拋磚引玉,你若有所收穫,我便欣然滿足。

寫在後面

繼續昨天出了一篇C語言的經典例題,反響挺好的,所以打算接着出,承諾明天一篇。
歡迎關注~

這裏是引用

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