객체 지향 Python (2 부) : 검색 엔진을 구현하는 방법은 무엇입니까?

검색 샘플이 로컬 디스크에 있다고 가정합니다. 편의를 위해 5 개의 파일 검색 만 제공하고 다음 코드에 콘텐츠를 넣습니다.


# 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이고 처리 된 콘텐츠는 저장됩니다. 처리 된 콘텐츠를 인덱스라고합니다.
  • 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 및 검색 인터페이스를 상속 및 구현하는 하위 클래스를 구현하며 동시에 add_corpus 함수도 상속합니다 (물론 다시 작성하는 것도 가능합니다). main () 함수.

새 생성자에서 self .__ id_to_texts = {}는 파일 내용에 파일 이름을 저장하는 데 사용되는 사전 인 자체 개인 변수를 초기화합니다.

process_corpus () 함수는 파일의 내용을 매우 간단하게 사전에 삽입합니다. 여기서 ID는 고유해야합니다. 그렇지 않으면 동일한 ID를 가진 새 콘텐츠가 이전 콘텐츠를 덮어 씁니다.

검색 사전을 직접 열거하고 검색 할 문자열을 찾습니다. 찾을 수 있으면 결과 목록에 ID를 넣고 끝에 반환하십시오.

알다시피, 매우 간단합니까? 이 과정은 항상 객체 지향적 사고를 통해 실행됩니다. 여기에서 몇 가지 질문을 정리했습니다. 간단한 리뷰로 생각할 수 있습니다.

  • 이제 부모 클래스와 하위 클래스 생성자의 호출 순서와 메서드에 대해 더 명확하게 알 수 있습니다.
  • 통합 중에 함수는 어떻게 다시 작성됩니까?
  • 기본 클래스는 어떻게 인터페이스 역할을합니까 (하위 클래스에서 재정의 된 함수를 직접 삭제하거나 함수의 매개 변수를 수정하여보고되는 오류를 볼 수 있음)?
  • 메소드와 변수는 어떻게 연결됩니까?

자, 검색 엔진의 주제로 돌아가 보겠습니다.

이 구현이 간단하다는 것도 알 수 있다고 생각합니다. 그러나 이것은 분명히 매우 비효율적 인 방법입니다. 인덱스 함수가 ​​아무 작업도하지 않기 때문에 각 인덱스 후에 많은 공간을 차지합니다. 각 검색에는 많은 시간이 걸립니다. 인덱스 라이브러리의 모든 파일을 다시 검색해야합니다. 말뭉치의 정보량이 n으로 간주되면 여기에서 시간 복잡도와 공간 복잡도는 O (n) 수준이어야합니다.

또한 또 다른 문제가 있습니다. 여기에서 쿼리는 한 단어 만 또는 여러 단어가 함께 연결될 수 있습니다. 여러 단어를 검색하고 싶은데 그 단어가 기사의 여러 곳에 흩어져 있다면 우리의 간단한 엔진은 무력합니다.

현재로서는 어떻게 최적화해야합니까?

가장 간단한 아이디어는 말뭉치의 단어 분할을 어휘로 취급하여 각 기사에 대한 모든 어휘 세트 만 저장하면되는 것입니다. Zipf의 법칙에 따르면 자연어 말뭉치에서 단어의 출현 빈도는 빈도 표의 순위에 반비례하여 멱 법칙 분포를 보여줍니다. 따라서 말뭉치 단어 분할을 실행하면 저장 및 검색 효율성이 크게 향상 될 수 있습니다.

단어 가방 和 거꾸로 된 색인

먼저 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

여기서 우리는 먼저 중국어로 bag-of-words 모델이라고하는 BOW 모델이라는 개념을 이해합니다. 이것은 NLP 분야에서 가장 일반적이고 단순한 모델 중 하나입니다.

텍스트가 문법, 구문, 단락 또는 단어가 나타나는 순서를 고려하지 않고이 텍스트를 이러한 단어의 모음으로 만 간주한다고 가정합니다. 따라서 id_to_texts를 id_to_words로 대체하여 모든 기사가 아닌 이러한 단어 만 저장하면되고 순서를 고려할 필요가 없습니다.

그중 process_corpus () 함수는 클래스 정적 함수 parse_text_to_words를 호출하여 기사를 단어 모음으로 나누고 세트에 넣은 다음 사전에 넣습니다.

search () 함수는 약간 더 복잡합니다. 여기에서 원하는 결과는 모든 검색 키워드가 동일한 기사에 나타나야한다는 것입니다. 그런 다음 쿼리를 분리하여 집합을 얻은 다음 색인의 각 기사와 함께 집합의 각 단어를 확인하여 찾고있는 단어가 여기에 있는지 확인해야합니다. 정적 함수 query_match가이 프로세스를 담당합니다.

이 두 함수는 상태 비 저장이고 객체의 개인 변수를 포함하지 않으며 (자체를 매개 변수로 사용하지 않음) 동일한 입력이 정확히 동일한 출력 결과를 얻을 수 있음을 알 수 있습니다. 따라서 정적으로 설정되어 다른 클래스에서 사용하기 편리합니다.

그러나 이렇게해도 질의 할 때마다 모든 ID를 트래버스해야합니다. Simple 모델에 비해 많은 시간을 절약했지만 인터넷에는 수억 페이지가 있고 트래버스 비용이 많이 듭니다. 그들 모두는 매번 여전히 너무 높습니다. 이 시점에서 어떻게 최적화 할 수 있습니까?

매번 쿼리하는 쿼리의 단어 수가 너무 많지 않고 일반적으로 몇 개에서 최대 12 개라고 생각했을 수 있습니다. 여기서부터 시작할 수 있습니까?

또한 bag-of-words 모델은 단어의 순서를 고려하지 않지만, 어떤 사람들은 단어가 순서대로 나타나기를 원하거나 검색된 단어가 텍스트에서 더 가깝기를 원합니다. 단어 모델은 무력합니다.

이 두 가지 관점에서 우리는 더 잘할 수 있습니까? 분명히 가능합니다. 다음 코드를 참조하십시오.


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 ()의 세 가지 기능 수정합니다.

이것은 실제로 대기업의 팀워크 방식이며 합리적인 계층 적 설계를 거친 후 각 계층의 논리는 자신의 업무 만 처리하면됩니다. 검색 엔진 커널을 반복적으로 업그레이드했을 때 주요 기능과 사용자 인터페이스는 변경되지 않았습니다. 물론, 회사가 새로운 프론트 엔드 엔지니어를 채용하고 사용자 인터페이스 부분을 수정해야하는 경우, 신규 이민자는 백엔드에 대해 너무 걱정할 필요가 없으며 데이터 상호 작용을 잘 수행하면됩니다.

계속해서 코드를 살펴보면 처음에 반전 된 인덱스가 있음을 알 수 있습니다. Inverted Index Model 또는 inverted index는 매우 잘 알려진 검색 엔진 방법입니다. 다음에 간단히 소개하겠습니다.

이름에서 알 수 있듯이 역 색인은 이번에는 차례로 단어-> id 사전을 유지한다는 것을 의미합니다. 그래서 상황이 갑자기 분명해졌습니다. 검색 할 때 우리가 원하는 query_word의 여러 개의 역 색인을 따로 추출한 다음이 목록에서 공통 요소를 찾으면됩니다. 이러한 공통 요소, 즉 ID가 우리입니다. 원하는 쿼리 결과입니다. 이런 식으로 우리는 모든 인덱스를 살펴 보는 당혹 스러움을 피할 수 있습니다.

process_corpus는 역 인덱스를 생성합니다. 여기에있는 코드는 매우 간결합니다. 산업 분야에서는 각 기사에 다른 ID를 표시하기 위해 고유 한 ID 생성기가 필요하며,이 unique_id에 따라 역 색인도 정렬해야합니다.

search () 함수에 관해서는 아마도 그것이 무엇을하는지 알고있을 것입니다. query_words에 따라 모든 reverse index를 가져옵니다. 만약 얻을 수 없다면 어떤 질의어가 아티클에 존재하지 않는다는 의미이며, 직접적으로 빈 값을 반환합니다. 가져 오면 "merge K"라는 알고리즘을 실행합니다. ordered arrays ", 원하는 ID를 가져 와서 반환합니다.

여기에 사용 된 알고리즘은 최적이 아니며 최적 쓰기에는 인덱스를 저장하는 데 가장 작은 힙이 필요합니다.

순회 문제가 해결되고, 두 번째 질문은 검색어가 순서대로 나타나는 것을 인식하고 싶거나 검색어가 텍스트에서 더 가깝게 나타나기를 원하면 어떻게 될까요?

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 구현은 실리콘 밸리 제조업체에서 자주 테스트하는 알고리즘 인터뷰 질문이기도합니다. 단순화를 위해 필루 패키지를 직접 사용합니다). 최근에 사용할 수 있습니다. 사용했던 물건과 오랫동안 사용하지 않은 물건은 점차적으로 제거됩니다.

따라서 여기의 캐시도 사용하기 매우 간단합니다. has () 함수를 호출하여 캐시에 있는지 확인합니다. 그렇다면 get 함수를 호출하여 결과를 직접 반환하고 그렇지 않은 경우 백그라운드 계산을 보냅니다. 결과를 캐시에 넣습니다.

BOWInvertedIndexEngineWithCache 클래스에 두 클래스의 다중 상속이 있음을 알 수 있습니다. 우선주의해야 할 것은 생성자입니다 (마지막 강의의 질문입니다. 생각해 보셨나요?). 다중 상속을위한 두 가지 초기화 방법이 있습니다. 별도로 살펴 보겠습니다.

첫 번째 메서드에서 다음 코드 줄을 사용하여 클래스의 첫 번째 부모 클래스를 직접 초기화합니다.


super(BOWInvertedIndexEngineWithCache, self).__init__()

그러나이 메서드를 사용할 때는 상속 체인의 최상위 부모 클래스가 개체를 상속해야합니다.

호출에 여러 생성자가있는 경우 두 번째 방법은, 다중 상속을 위해, 우리는 전통적인 방법 LRUCache를 사용해야합니다. 초기화 (자신을).

둘째, search () 함수가 BOWInvertedIndexEngineWithCache 하위 클래스에 의해 다시 오버로드되었지만 여전히 BOWInvertedIndexEngine의 search () 함수를 호출해야합니다. 어떻게해야합니까? 다음 코드 줄을보십시오.


super(BOWInvertedIndexEngineWithCache, self).search(query)

재정의 된 부모 클래스의 함수를 강제로 호출 할 수 있습니다.

이런 식으로 BOWInvertedIndexEngine의 코드에 영향을주지 않고 간결하게 캐싱을 구현했습니다.

추천

출처blog.csdn.net/qq_41485273/article/details/114104640