1. 程式人生 > 實用技巧 >一個搜尋引擎由搜尋器、索引器、檢索器和使用者介面四個部分組成

一個搜尋引擎由搜尋器、索引器、檢索器和使用者介面四個部分組成

搜尋器,通俗來講就是我們常提到的爬蟲(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.')

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 程式碼的情況下。這部分內容希望你多讀幾遍,自己揣摩清楚,通過這個例子多多體會繼承的優勢。