Русский
Русский
English
Статистика
Реклама

Полнотекстовый поиск

Перевод Как сделать полнотекстовую поисковую машину на 150 строках кода Python

07.04.2021 20:14:16 | Автор: admin

Полнотекстовый поиск неотъемлемая часть нашей жизни. Разыскать нужные материалы в сервисе облачного хранения документов, найти фильм в Netflix, купить туалетную бумагу на Ozon или отыскать с помощью сервисов Google интересующую информацию в Интернете наверняка вы сегодня уже не раз отправляли похожие запросы на поиск нужной информации в невообразимых объёмах неструктурированных данных. И что удивительнее всего несмотря на то что вы осуществляли поиск среди миллионов (или даже миллиардов) записей, вы получали ответ за считанные миллисекунды. Специально к старту нового потока курса Fullstack-разработчик наPython в данной статье мы рассмотрим основные компоненты полнотекстовой поисковой машины и попытаемся создать систему, которая сможет за миллисекунды находить информацию в миллионах документов и ранжировать результаты по релевантности, причём всю систему можно воплотить всего в 150 строках кода на Python!


Данные

Весь код, использованный в данной статье, можно найти на Github. Здесь я приведу ссылки на фрагменты кода, и вы сами сможете попробовать с ним поработать. Весь пример можно запустить, установив файл requirements (pip install -r requirements.txt) и запустив на выполнение файл python run.py. Данная команда загрузит все данные и запустит пример поискового запроса с ранжированием и без ранжирования.

Перед тем как начать создание поисковой машины, нужно подготовить полнотекстовые неструктурированные данные, в которых будет осуществляться поиск. Искать будем в аннотациях статей из английской Википедии. Это заархивированный с помощью gzip утилиты сжатия и восстановления файлов XML-файл размером около 785 Мбайт, содержащий приблизительно 6,27 миллионов аннотаций [1]. Для загрузки архивированного XML-файла я написал простую функцию, но вы можете поступить проще загрузить его вручную.

Подготовка данных

Все аннотации хранятся в одном большом XML-файле. Аннотации в таком файле отделяются друг от друга элементом <doc> и выглядят примерно так (элементы, которые нас не интересуют, я опустил):

<doc>    <title>Wikipedia: London Beer Flood</title>    <url>https://en.wikipedia.org/wiki/London_Beer_Flood</url>    <abstract>The London Beer Flood was an accident at Meux & Co's Horse Shoe Brewery, London, on 17 October 1814. It took place when one of the  wooden vats of fermenting porter burst.</abstract>    ...</doc>

Интерес для нас представляют следующие разделы: title, url abstract (сам текст аннотации). Чтобы было удобнее обращаться к данным, представим документы как класс данных Python. Добавим свойство, конкатенирующее заголовок и содержание аннотации. Код можно взять здесь.

from dataclasses import dataclass@dataclassclass Abstract:    """Wikipedia abstract"""    ID: int    title: str    abstract: str    url: str    @property    def fulltext(self):        return ' '.join([self.title, self.abstract])

Затем нужно извлечь данные из XML-файла, осуществить их синтаксический разбор и создать экземпляры нашего объекта Abstract. Весь заархивированный XML-файл загружать в память не нужно, будем работать с потоком данных [2]. Каждому документу присвоим собственный идентификатор (ID) согласно порядку загрузки (то есть первому документу присваивается ID=1, второму ID=2 и т. д.). Код можно взять здесь.

import gzipfrom lxml import etreefrom search.documents import Abstractdef load_documents():    # open a filehandle to the gzipped Wikipedia dump    with gzip.open('data/enwiki.latest-abstract.xml.gz', 'rb') as f:        doc_id = 1        # iterparse will yield the entire `doc` element once it finds the        # closing `</doc>` tag        for _, element in etree.iterparse(f, events=('end',), tag='doc'):            title = element.findtext('./title')            url = element.findtext('./url')            abstract = element.findtext('./abstract')            yield Abstract(ID=doc_id, title=title, url=url, abstract=abstract)            doc_id += 1            # the `element.clear()` call will explicitly free up the memory            # used to store the element            element.clear()

Индексирование

Полученные данные сохраняем в структуре данных, называемой обращённый указатель, или список документов. Полученная структура напоминает алфавитный указатель, который обычно приводится в конце книги, представляющий собой перечень встречающихся в книге соответствующих слов и понятий с указанием номеров страниц, на которых такие слова и понятия встречаются.

Так выглядит книжный алфавитный указательТак выглядит книжный алфавитный указатель

Фактически мы создаём словарь, в котором будет проведено сопоставление всех слов нашего набора документов с идентификаторами документов, в которых встречаются эти слова. Выглядеть это будет примерно так:

{    ...    "london": [5245250, 2623812, 133455, 3672401, ...],    "beer": [1921376, 4411744, 684389, 2019685, ...],    "flood": [3772355, 2895814, 3461065, 5132238, ...],    ...}

Обратите внимание, что в приведённом выше примере элементы словаря записаны строчными буквами. Перед созданием указателя мы должны разбить исходный текст на отдельные слова, или лексемы, то есть проанализировать текст. Сначала разобьём текст на отдельные слова (по-научному: выделим лексемы), а затем применим к лексемам ряд фильтров, например фильтр преобразования в нижний регистр или фильтр выделения основы слова (в принципе, фильтры можно и не применять), это поможет получать более адекватные результаты поисковых запросов.

Выделение лексемВыделение лексем

Анализ

Применим простейший способ выделения лексем: разобьём текст в местах, в которых встречаются пробелы. Затем к каждой лексеме применим пару фильтров: переведём текст лексемы в нижний регистр, удалим любые знаки препинания, исключим 25 наиболее распространённых английских слов (а также слово википедия, так как оно встречается во всех заголовках всех аннотаций) и к каждому слову применим фильтр выделения основы слова (после этой операции разные формы слова, например, красный и краснота будут соответствовать одной и той же основе красн [3]).

Функции выделения лексем и перевода в нижний регистр довольно просты:

import StemmerSTEMMER = Stemmer.Stemmer('english')def tokenize(text):    return text.split()def lowercase_filter(text):    return [token.lower() for token in tokens]def stem_filter(tokens):    return STEMMER.stemWords(tokens)

От знаков пунктуации избавиться также просто к набору пунктуационных знаков применяется регулярное выражение:

import reimport stringPUNCTUATION = re.compile('[%s]' % re.escape(string.punctuation))def punctuation_filter(tokens):    return [PUNCTUATION.sub('', token) for token in tokens]

Игнорируемые слова это самые распространённые слова, которые, как мы полагаем, будут встречаться практически в каждом документе. Включение в указатель игнорируемых слов нежелательно, так как по поисковому запросу будет выдаваться практически каждый документ, и результат поиска станет малоинформативен (не говоря уже о том, что он займёт значительный объём памяти). Поэтому во время индексирования мы такие слова отфильтруем. В заголовках аннотаций статей Википедии содержится слово Википедия, поэтому это слово мы также добавляем в список игнорируемых слов. В итоге мы внесли в список игнорируемых слов 25 самых распространённых слов английского языка, в том числе слово Википедия.

# top 25 most common words in English and "wikipedia":# https://en.wikipedia.org/wiki/Most_common_words_in_EnglishSTOPWORDS = set(['the', 'be', 'to', 'of', 'and', 'a', 'in', 'that', 'have',                 'I', 'it', 'for', 'not', 'on', 'with', 'he', 'as', 'you',                 'do', 'at', 'this', 'but', 'his', 'by', 'from', 'wikipedia'])def stopword_filter(tokens):    return [token for token in tokens if token not in STOPWORDS]

Применяя все описанные выше фильтры, мы создаём функцию анализа (analyze), которая будет применяться к тексту каждой аннотации; данная функция разбивает текст на отдельные слова (или лексемы), а затем последовательно применяет каждый фильтр к списку лексем. Порядок применения фильтров важен, так как в списке игнорируемых слов не выделены основы слов, поэтому фильтр stopword_filter нужно применить до фильтра stem_filter.

def analyze(text):    tokens = tokenize(text)    tokens = lowercase_filter(tokens)    tokens = punctuation_filter(tokens)    tokens = stopword_filter(tokens)    tokens = stem_filter(tokens)    return [token for token in tokens if token]

Индексирование набора документов

Создадим класс Index, в котором будет храниться указатель (index) и документы (documents). В словаре documents будут храниться классы данных по идентификатору (ID), а ключи указателя index будут представлять собой лексемы со значениями идентификаторов документов, в которых встречаются лексемы:

class Index:    def __init__(self):        self.index = {}        self.documents = {}    def index_document(self, document):        if document.ID not in self.documents:            self.documents[document.ID] = document        for token in analyze(document.fulltext):            if token not in self.index:                self.index[token] = set()            self.index[token].add(document.ID)

Поиск

Теперь, когда все лексемы проиндексированы, задача выполнения поискового запроса превращается в задачу анализа текста запроса с помощью того же анализатора, который мы применили к документам; таким образом, мы получим лексемы, которые будут совпадать с лексемами, имеющимися в указателе. Для каждой лексемы осуществим поиск в словаре, выявим идентификаторы документов, в которых встречается такая лексема. Затем выявим идентификаторы документов во всех таких наборах (другими словами, чтобы документ соответствовал запросу, он должен содержать все лексемы, присутствующие в запросе). После этого возьмём итоговой список идентификаторов документов и выполним выборку данных из нашего хранилища документов [4].

def _results(self, analyzed_query):    return [self.index.get(token, set()) for token in analyzed_query]def search(self, query):    """    Boolean search; this will return documents that contain all words from the    query, but not rank them (sets are fast, but unordered).    """    analyzed_query = analyze(query)    results = self._results(analyzed_query)    documents = [self.documents[doc_id] for doc_id in set.intersection(*results)]    return documentsIn [1]: index.search('London Beer Flood')search took 0.16307830810546875 millisecondsOut[1]:[Abstract(ID=1501027, title='Wikipedia: Horse Shoe Brewery', abstract='The Horse Shoe Brewery was an English brewery in the City of Westminster that was established in 1764 and became a major producer of porter, from 1809 as Henry Meux & Co. It was the site of the London Beer Flood in 1814, which killed eight people after a porter vat burst.', url='https://en.wikipedia.org/wiki/Horse_Shoe_Brewery'), Abstract(ID=1828015, title='Wikipedia: London Beer Flood', abstract="The London Beer Flood was an accident at Meux & Co's Horse Shoe Brewery, London, on 17 October 1814. It took place when one of the  wooden vats of fermenting porter burst.", url='https://en.wikipedia.org/wiki/London_Beer_Flood')]

Запросы получаются очень точными, особенно если строка запроса достаточно длинная (чем больше лексем содержит запрос, тем меньше вероятность выдачи документа, содержащего все такие лексемы). Функцию поиска можно оптимизировать, отдав предпочтение полноте, а не точности поиска, то есть пользователь может указать, что результатом поискового запроса может быть документ, в котором содержится лишь одна из указанных им лексем:

def search(self, query, search_type='AND'):    """    Still boolean search; this will return documents that contain either all words    from the query or just one of them, depending on the search_type specified.    We are still not ranking the results (sets are fast, but unordered).    """    if search_type not in ('AND', 'OR'):        return []    analyzed_query = analyze(query)    results = self._results(analyzed_query)    if search_type == 'AND':        # all tokens must be in the document        documents = [self.documents[doc_id] for doc_id in set.intersection(*results)]    if search_type == 'OR':        # only one token has to be in the document        documents = [self.documents[doc_id] for doc_id in set.union(*results)]    return documentsIn [2]: index.search('London Beer Flood', search_type='OR')search took 0.02816295623779297 secondsOut[2]:[Abstract(ID=5505026, title='Wikipedia: Addie Pryor', abstract='| birth_place    = London, England', url='https://en.wikipedia.org/wiki/Addie_Pryor'), Abstract(ID=1572868, title='Wikipedia: Tim Steward', abstract='|birth_place         = London, United Kingdom', url='https://en.wikipedia.org/wiki/Tim_Steward'), Abstract(ID=5111814, title='Wikipedia: 1877 Birthday Honours', abstract='The 1877 Birthday Honours were appointments by Queen Victoria to various orders and honours to reward and highlight good works by citizens of the British Empire. The appointments were made to celebrate the official birthday of the Queen, and were published in The London Gazette on 30 May and 2 June 1877.', url='https://en.wikipedia.org/wiki/1877_Birthday_Honours'), ...In [3]: len(index.search('London Beer Flood', search_type='OR'))search took 0.029065370559692383 secondsOut[3]: 49627

Релевантность

С помощью элементарных команд Python мы создали довольно быстро работающую поисковую систему, но остался один аспект, которого явно не хватает в нашей маленькой поисковой машине, а именно не учтён принцип релевантности. Сейчас программа поиска составлена таким образом, что пользователь получает неупорядоченный список документов и должен сам выбирать из полученного списка действительно нужные ему документы. Но, если результатов очень много, справиться с такой задачей не под силу никакому пользователю (в нашем примере OR программа выдала почти 50000 результатов).

Вот тут-то и пригодится алгоритм ранжирования по релевантности, то есть алгоритм, который присваивал бы каждому документу собственный ранг насколько точно документ соответствует запросу, и затем упорядочивал бы полученный список по таким рангам. Самый примитивный и простой способ присвоения ранга документу при выполнении запроса состоит в том, чтобы просто подсчитать, как часто в таком документе упоминается конкретное слово. Идея вроде бы логичная: чем чаще в документе упоминается термин, тем больше вероятность того, что это именно тот документ, который мы ищем!

Частота вхождения терминов

Расширим наш класс данных Abstract, чтобы можно было вычислять и сохранять частоту вхождения терминов в процессе индексирования. Соответственно, если нам потребуется ранжировать наш неупорядоченный список документов, у нас будет для этого вся нужная информация:

# in documents.pyfrom collections import Counterfrom .analysis import analyze@dataclassclass Abstract:    # snip    def analyze(self):        # Counter will create a dictionary counting the unique values in an array:        # {'london': 12, 'beer': 3, ...}        self.term_frequencies = Counter(analyze(self.fulltext))    def term_frequency(self, term):        return self.term_frequencies.get(term, 0)

При индексировании должен осуществляется подсчёт частот вхождения терминов:

# in index.py we add `document.analyze()def index_document(self, document):    if document.ID not in self.documents:        self.documents[document.ID] = document        document.analyze()

Внесём изменения в функцию поиска таким образом, чтобы к документам результирующего набора можно было применить ранжирование. Выборку документов будем осуществлять с помощью одного и того же логического запроса из указателя и базы данных документов, а затем для каждого документа такого результирующего множества подсчитаем, сколько раз встречается в этом документе каждый термин.

def search(self, query, search_type='AND', rank=True):    # snip    if rank:        return self.rank(analyzed_query, documents)    return documentsdef rank(self, analyzed_query, documents):    results = []    if not documents:        return results    for document in documents:        score = sum([document.term_frequency(token) for token in analyzed_query])        results.append((document, score))    return sorted(results, key=lambda doc: doc[1], reverse=True)

Обратная частота документов

Полученная функция работает уже намного лучше, но у нее всё равно имеются определённые недостатки. Мы подразумевали, что при оценке релевантности запроса все термины запроса имеют один и тот же вес. Но, как нетрудно догадаться, при определении релевантности некоторые термины имеют либо крайне малую, либо вообще нулевую различающую способность; например, в наборе из большого количества документов, относящихся к пиву, термин пиво будет встречаться практически в каждом документе (ранее мы уже пытались обойти эту проблему, исключив из указателя 25 самых распространённых английских слов). Поиск слова пиво приведёт к тому, что мы получим лишь маловразумительный набор хаотично упорядоченных документов.

Чтобы решить проблему, добавим в наш алгоритм определения релевантности ещё один компонент, занижающий в итоговом результате вес терминов, очень часто встречающихся в указателе. В принципе, можно было бы использовать значение частоты вхождения термина (то есть насколько часто такой термин встречается во всех документах), однако на практике вместо этого значения используется значение частоты документов (то есть какое количество документов в указателе содержат данный термин). Поскольку наша задача заключается в ранжировании именно документов, формировать статистику имеет смысл на уровне документов.

Обратная частота документов для термина определяется делением количества документов (N) в указателе на количество документов, содержащих термин, и взятием логарифма полученного частного.

Обратная частота документов (IDF); формула взята из https://moz.com/blog/inverse-document-frequency-and-the-importance-of-uniquenessОбратная частота документов (IDF); формула взята из https://moz.com/blog/inverse-document-frequency-and-the-importance-of-uniqueness

При ранжировании значение частоты термина умножается на значение обратной частоты документа, и, следовательно, документы, в которых присутствуют термины, редко встречающиеся в наборе документов, будут более релевантными [5]. Значение обратной частоты документов можно легко рассчитать по указателю:

# index.pyimport mathdef document_frequency(self, token):    return len(self.index.get(token, set()))def inverse_document_frequency(self, token):    # Manning, Hinrich and Schtze use log10, so we do too, even though it    # doesn't really matter which log we use anyway    # https://nlp.stanford.edu/IR-book/html/htmledition/inverse-document-frequency-1.html    return math.log10(len(self.documents) / self.document_frequency(token))def rank(self, analyzed_query, documents):    results = []    if not documents:        return results    for document in documents:        score = 0.0        for token in analyzed_query:            tf = document.term_frequency(token)            idf = self.inverse_document_frequency(token)            score += tf * idf        results.append((document, score))    return sorted(results, key=lambda doc: doc[1], reverse=True)

Будущая работа

Мы создали элементарную информационно-поисковую систему всего из нескольких десятков строчек кода Python! Код целиком приведён на Github. Также я написал вспомогательную функцию, загружающую аннотации статей Википедии и создающую указатель. Установите файл requirements, запустите его в выбранной вами консоли Python и получайте удовольствие от работы со структурами данных и операциями поиска.

Данная статья написана с единственной целью привести пример реализации концепции поиска и продемонстрировать, что поисковые запросы (даже с ранжированием) могут выполняться очень быстро (например, на своём ноутбуке с медленным Python я могу осуществлять поиск и ранжирование среди 6,27 миллионов документов). Статью ни в коем случае не следует рассматривать как инструкцию по созданию программного обеспечения промышленного уровня. Моя поисковая машина запускается полностью в памяти ноутбука, но такие библиотеки, как Lucene, используют сверхпроизводительные структуры данных и даже оптимизируют дисковые операции, а такие программные комплексы, как Elasticsearch и Solr, распространяют библиотеку Lucene на сотни, а иногда и тысячи машин.

Но даже наш простенький алгоритм можно существенно улучшить. Например, мы молчаливо предполагали, что каждое поле документа вносит в релевантность одинаковый вклад, но, если поразмыслить, так быть не должно ведь термин, присутствующий в заголовке, очевидно, должен иметь больший вес, чем термин, встречающийся в содержании аннотации. Другой перспективной идеей может стать более продвинутый синтаксический анализ запроса должны ли совпадать все термины? только один термин? или несколько? Зная ответы на эти вопросы, можно повысить качество работы поисковых запросов. Также почему бы не исключить из запроса определённые термины? почему бы не применить операции AND и OR к отдельным терминам? Можно ли сохранить указатель на диск и вывести его, таким образом, за пределы оперативной памяти ноутбука?

Благодаря своей универсальности и распространённости, Python уже который год находится в топе языков программирования и де-факто стал основным языком для работы с данными. Если вы хотите расширить свои компетенции и освоить этот язык под руководством крутых менторов приходите на курс Fullstack-разработчик на Python.

Узнайте, как прокачаться в других инженерных специальностях или освоить их с нуля:

Другие профессии и курсы
  1. Аннотация это, как правило, первый абзац или первая пара предложений статьи в Википедии. Полный объём заархивированного XML-файла составляет приблизительно 796 Мбайт. Если вы захотите самостоятельно поэкспериментировать с кодом, можно воспользоваться доступными архивами меньшего размера (с ограниченным количеством аннотаций); синтаксический разбор и индексирование XML-файла займут довольно большое время и потребуют значительных объёмов памяти.

  2. Весь набор данных и указатель будут храниться в памяти, поэтому нет нужды также хранить в памяти необработанные данные.

  3. Много ли преимуществ даёт фильтр выделения основы слова? Это тема для отдельного обсуждения. Применение такого фильтра позволит уменьшить общий размер указателя (то есть уменьшить количество уникальных слов), однако операция выделения основы слова базируется на эвристике, и мы, сами того не желая, можем отсеять потенциально ценную информацию. Например, если слова университет, универсальный, университеты и универсиада усечь до основы универс, мы потеряем способность различать значения этих слов, а это отрицательно скажется на релевантности. Более подробная информация о выделении основы слов (и лемматизации) приведена в этой отличной статье.

  4. При решении задачи мы используем оперативную память ноутбука. Но на практике применяется другой способ, не связанный с хранением указателя в памяти. Поисковая система Elasticsearch хранит данные в обычном текстовом формате JSON на диске, в самой библиотеке Lucene (базовой библиотеке поиска и индексирования) хранятся только индексированные данные. Многие другие поисковые системы просто возвращают упорядоченный список идентификаторов документов, который затем используется для извлечения и выдачи пользователям данных из базы данных или другого сервиса. Особенно это актуально для крупных корпораций, в которых полное реиндексирование всех данных обходится недёшево. Как правило, в поисковой системе хранятся только данные, связанные с обеспечением релевантности (а не атрибуты, используемые только для презентационных целей).

  5. Для более глубокого понимания алгоритма рекомендую ознакомиться со следующими публикациями: What is TF-IDF? и Term frequency and weighting

Подробнее..

Из песочницы Как построить полнотекстовый поиск с помощью нейронных сетей

25.10.2020 14:05:47 | Автор: admin

Почему с помощью обычного полнотекстового поиска сложно искать очень короткие документы и как быть, если хочется это сделать.



Введение


Мы все постоянно сталкиваемся с так называемым полнотекстовым поиском нахождением документов по поисковой фразе. Самый известный пример это поиск Google.


Существует немало инструментов для построения полнотекстового поиска документов. Достаточно популярным решением является, например, Elasticsearch. На его основе можно организовать поиск по огромным базам данных с десятками миллионов документов.


Мы в DD Planet разрабатываем сервисы поиска и аналитики по базам данных B2B-закупок и активно используем Elasticsearch. Но когда мы попытались организовать поиск и сопоставление очень коротких документов (наименований товаров), то столкнулись с рядом проблем.


Как работает полнотекстовый поиск


Большинство систем полнотекстового поиска, в том числе и Elasticsearch, основаны на обратном индексе структуре данных, где для каждого слова из базы документов хранятся все документы, в которых оно встречается. Обратный индекс используется для поиска по текстам.


Пусть у нас есть корпус из базы из трех текстов:


T0="синяя ручка", T1="синее дерево", T2="красное дерево",

тогда обратный индекс будет выглядеть следующим образом:


"синий": {0, 1}"ручка": {0}"дерево": {1, 2}"красный": {2}

Поиск по одному слову очевиден это просто список всех документов, соответствующих поисковому слову. При поиске фразы можно производить как пересечение, так объединение результатов поиска по отдельным словам с целью ранжировать результаты по релевантности. Рассмотрим, например, поиск фразы красная ручка. Поиск по слову красный дает результат {2}, по слову ручка {0}. Пересечение пусто, поэтому точно совпадения нет. Если считать частичное совпадение, то получится два документа {0, 2} c степенью соответствия . В реальных поисковых системах обычно применяются более сложные методики ранжирования, например, с учетом TF-IDF, однако в результаты выдачи не может попасть документ без текстового совпадения с поисковым запросом.


Проблемы полнотекстового поиска


Если нам необходимо не просто искать внутри данных, а сопоставлять их, находить похожую информацию или те же данные, только написанные по-другому, то мы сталкиваемся со следующими проблемами:


  1. Контекстная значимость слов.
    Вернемся к предыдущему примеру: словосочетания синяя ручка и красное дерево отличаются от фразы красная ручка одинаково одним слово, имеют примерно одну релевантность, однако семантически красная ручка намного ближе к синей ручке, чем к красному дереву. На мой взгляд, с этим сложнее всего бороться.
    Можно применить технику с весами слов в поисковом запросе. Но такое решение переводит одну задачу в другую: непонятно, как определить эти веса адекватно. Самым простым вариантом является, как уже отмечалось, вычисление TF-IDF, но этот подход как минимум не учитывает порядка слов.
  2. Разное написание одинаковых по значению слов.
    Другая проблема сокращения и синонимы в наименованиях товарных позиций, например, не только лист бумаги А4, а также А4, бумага для принтера, лист А4 и т. д.
    Решение проблемы загрузка в Elasticsearch словарей синонимов. Однако сборка обширных библиотек синонимов если и реальна, то очень трудоемка, особенно в узкоспециализированных областях.
  3. Разное значение одинаковых по написанию слов.
    Обратной проблемой является то, что исходя из контекста слова могут иметь разное значение. Например, во фразах ключ дверной и ключ активации Windows слово ключ имеет совершенно разное значение.

NLP как решение проблем


Одним из технологичных способов решить вышеупомянутые задачи является применение NLP подходов. NLP (Natural Language Processing) общее направление искусственного интеллекта и математической лингвистики, которое изучает проблемы компьютерного анализа и синтеза естественных языков.


NLP использует семантико-синтаксический анализ введенного текста, учитывает естественно-языковые средства. Сейчас существует множество готовых моделей, фреймворков и уже решенных проблем в этой области.


Как определить степень похожести фраз


В NLP есть следующая задача Paraphrase Identification это изучение двух текстовых объектов (например, предложений) и определения, имеют ли они одинаковое значение (то есть являются парафразами).Пример парафраз: Завтра кафе работает до 17:00 и На следующий день кафетерий будет работать до пяти вечера. Почему это нам интересно? С помощью парафразирования мы можем анализировать текстовые описания товаров и выяснять, связаны ли они с одним товаром или нет.


Эта задача хорошо исследована. Существуют большие базы данных парафраз на русском и английском языках. В качестве инструмента для решения этой проблемы мы обратились к библиотеке DeepPavlov.ai [1], мощному фреймворку, разработанному в МФТИ. В нем собраны различные подходы и модели, связанные с обработкой русского языка.


Следующим шагом было получение базы парафраз. Так как наша тематика достаточна специфична (поиск товаров), нам нужна собственная база данных. Мы воспользовались агрегаторами магазинов вида Яндекс.Маркета. Взяли одно название товара с Маркета и другое название этого же товара в интернет-магазине.


Экспериментируя с разными моделями, имеющимися в DeepPavlov, мы поняли основной вклад в результат дают не настройки модели, а качество наших данных.


Как организовать поиск, когда у нас почти ничего нет


Однако нам нужно не просто определять степени соответствия, а находить наиболее похожие товары. Как это сделать? Можно использовать полный перебор, однако он имеет сложность поиска $inline$O(N)$inline$ и занимает много времени, тогда как обратный индекс Elasticsearch имеет сложность поиска $inline$O(\log N)$inline$. Попробуем достичь такой же скорости поиска.


Что мы имеем: множество строк и некоторую функцию, описывающую степень соответствия между этими строками. Будем далее называть эту функцию расстоянием.


Если покопаться в теории, можно наткнуться на следующую математическую структуру: метрическое пространство это множество, на котором определена функция $inline$d$inline$ такая, что выполняются следующие аксиомы:


  1. Расстояние равно нулю только при равенстве двух объектов $inline$d(x, y) = 0$inline$, если $inline$x=y$inline$.
  2. Расстояние симметрично $inline$d(x, y) = d(y, x).$inline$
  3. Самая сложная из аксиом неравенство треугольника $inline$d(x,z) d(x,y)+d(y,z).$inline$ Это значит, что сумма расстояний не меньше, чем расстояние между крайними объектами.

Зачем нам нужна такая сложная структура? Она нужна для эффективного решения задач поиска ближайших соседей (Nearest neighbor search) в нашем случае поиска похожих товаров. Такая задача может быть решена в метрическом пространстве с помощью структуры vantage-point tree, поиск в которой имеет асимптотику поиска $inline$O(\log N)$inline$ [2]. Заметим, если бы речь шла о векторах, эта задача решилась намного проще, например, с помощью Kd-деревьев. Однако так как наши объекты более абстрактны, все гораздо сложнее.


Vantage-point tree


Посмотрим, как работает vantage-point tree [3]. Оно напоминает ball-tree, используемый в векторных пространствах. Его структура представляет собой бинарное дерево. Рассмотрим, как оно строится. Мы берем в качестве вершины некоторый объект из множества (vantage-point) и рисуем вокруг него окружность (изображена на рисунке ниже).



Все объекты, находящиеся внутри окружности (то есть на расстоянии $inline$S$inline$ от vantage-point), идут в левую часть дерева. Остальные в правую часть. Далее процедура повторяется для каждого поддерева. Чтобы дерево было сбалансировано, надо стараться выбирать S так, чтобы исходное множество объектов делилось на примерно равные части. Это несложно сделать, если заранее просчитать все расстояния и найти их медиану.


Предположим, мы хотим найти K ближайших соседей к точке $inline$X$inline$ (отмечена красным крестиком на рисунке). Мы еще не нашли ни одной точки, поэтому в качестве ближайшего кандидата берем вершину дерева (возможно, потом мы удалим эту точку). Запоминаем значение $inline$D$inline$ текущее расстояние до самого дальнего кандидата. Теперь надо решить, нужно ли нам искать в обоих поддеревьях или достаточно проверить только одно и них.



Так как точка $inline$X$inline$ находится внутри нашей окружности, мы должны сначала проверить внутреннее поддерево. Находим в этом поддереве синюю точку и обновляем $inline$D$inline$. Надо ли нам проверять внешнее поддерево? Мы рассмотрели все точки внутри окружности, но так как $inline$D > T$inline$ (расстояние от X до окружности), за пределами окружности могут существовать более близкие точки. Значит, нам надо проверить внешнее поддерево.



Однако если мы уже собрали K ближайших точек, а $inline$D T$inline$, то проверять внешнее поддерево нет необходимости. Это и обеспечивает выигрыш в производительности по сравнению с полным перебором.


Метрическая функция


Мы решили использовать нашу функцию парафразирования $inline$\left(f(x,y)\right)$inline$ в качестве расстояния для построения vantage-point tree и столкнулись с рядом проблем:


  1. Не выполняется первая аксиома расстояние между точками равно нулю только при условии, что они равны. Это условие не выполняется уже на обучающей выборке, так как мы полагали, что некоторые фразы означают одно и то же при их разном написании. Для решения проблемы мы добавили к основному расстоянию cosine расстояние по Doc2Vec с небольшим весом оно будет гарантировано различно для разных объектов.


    $$display$$d(x,y)=f(x,y)+ \cdot S_{Doc2Vec}(x \cdot y )$$display$$


    Однако вопрос подбора весового коэффициента остается открытым с этим надо экспериментировать на реальных данных.


  2. Модель несимметрична. Почему? Понять это достаточно сложно, но, скорее всего, проблема в ошибке округления float32. Из-за сложности модели ошибка накапливается в слоях нейронов. Эта проблема легко решается вычислением сначала расстояния от $inline$x$inline$ до $inline$y$inline$, а потом наоборот, и суммированием результата.


    $$display$$d(x,y)=f(x,y)+f(y,x)$$display$$


  3. Не всегда выполняется неравенство треугольника $inline$d(x,z) d(x,y)+d(y,z)$inline$. Причиной этого являются особенности обучения нашей модели парафразирования. Например, рассмотрим фразы


    x="красная машина", y="синяя машина", z="синий микроавтобус"
    

    Это наглядный пример, когда $inline$d(x,z) d(x,y)+d(y,z).$inline$ Из-за этого поисковая выдача иногда получается неупорядоченной. Исправить аналитически это не получается, но компенсировать это может увеличение веса расстояния Doc2Vec для него неравенство треугольника выполняется всегда.



Выполнив формальные критерии, описанные выше, мы столкнулись на практике с другой проблемой время поиска росло линейно, а не логарифмически, как мы ожидали. Покопавшись в литературе, мы выяснили: логарифмическая скорость поиска имеет место для примерно равномерного распределения точек в пространстве [2]. А у нас объекты распределены неравномерно они чаще дальше, чем ближе.


Поэтому нам пришла следующая идея. Посчитаем все возможные пары расстояний и нанесем их на график (на рисунке слева). Если мы растянем верхнюю часть графика, а нижнюю сожмем, то точки будут распределены примерно одинаково (как показано на рисунке справа). Заметим, что порядок в множестве расстояний от такого преобразования не изменится. Можно при необходимости увеличить количество интервалов и получить идеальное распределение расстояний.



Итоги


Пока что мы проводили тестирование на небольших базах данных (до миллиона объектов), и там наш поиск работает достаточно хорошо. Время сборки дерева растет практически линейно с небольшим логарифмическим коэффициентом.



Рост времени поиска близок к логарифмическому. Почему рост времени поиска не всегда равномерен? Дело в том, что работа поиска зависит от распределения точек в пространстве. Возникает интересный вопрос: если бы мы могли управлять распределением точек в пространстве, то какое распределение было бы оптимальным? В литературе по vantage-point tree этот вопрос обычно не обсуждается, но обсуждается смежный вопрос как оптимально выбрать vantage-point.



Например, в метрическом пространстве нужно брать самую крайнюю точку[2], тогда на границе будет меньше всего точек и поиск станет лучше. Но эта идея реализуема только в пространстве с понятиями лево, право и край. В пространстве без порядка объектов эта идея не работает.


Наше решение не является серебряной пулей. Мы пробовали его для поиска товаров, и получилось отлично. Если выбрать другую тематику, результаты могут быть совершенно другими.


Сейчас мы работаем над развитием и оптимизацией технологии. Мы хотим создать не просто алгоритмические или математические структуры, а реально работающие поисковые системы. Текущие результаты можно посмотреть на GitHub или попробовать через pip install nlp-text-search.


Литература


[1] http://docs.deeppavlov.ai/en/master/.


[2] Yianilos (1993). Data structures and algorithms for nearest neighbor search in general metric spaces. Fourth annual ACM-SIAM symposium on Discrete algorithms. Society for Industrial and Applied Mathematics Philadelphia, PA, USA. pp. 311321. pny93. http://web.cs.iastate.edu/~honavar/nndatastructures.pdf.


[3] http://stevehanov.ca/blog/?id=130

Подробнее..

Manticore Search форк Sphinx отчёт за 3 года

08.02.2021 18:21:29 | Автор: admin

В мае 2017 мы, команда Manticore Software, сделали форк Sphinxsearch 2.3.2, который назвали Manticore Search. Ниже вы найдёте краткий отчёт о проделанной работе за три с половиной года, прошедших с момента форка.

Зачем был нужен форк?

Прежде всего, зачем мы сделали форк? В конце 2016 работа над Sphinxsearch была приостановлена. Пользователи, которые пользовались продуктом и поддерживали развитие проекта волновались, так как:

  • баги не фиксились

  • новые фичи, которые были давно обещаны не производились

  • коммуникация с командой Sphinx была затруднена.

По прошествии нескольких месяцев ситуация не поменялась и в середине июня группа инициативных опытных пользователей и клиентов поддержки Sphinx собрались, и было принято решение попытаться сохранить продукт в виде форка, под именем Manticore Search. Удалось собрать большую часть команды Sphinx (а именно тех, кто занимался непосредственно разработкой и поддержкой пользователей), привлечь инвестиции и в короткие сроки восстановить полноценную работу над проектом.

Чего мы хотели добиться?

Целей у форка было три:

  1. Поддержка кода в целом: багфиксинг, мелкие и крупные доработки

  2. Поддержка пользователей Sphinx и Manticore

  3. И, по возможности, более интенсивное развитие продукта, чем это было раньше. К большому сожалению, на момент форка Elasticsearch уже много лет как обогнал Sphinx по многим показателям. Такие вещи, как:

    1. отсутствие репликации

    2. отсутствие auto id

    3. отсутствие JSON интерфейса

    4. невозможность создать/удалить индекс на лету

    5. отсутствие хранилища документов

    6. слаборазвитые real-time индексы

    7. фокусировка на полнотекстовом поиске, а не поиске в целом

    делали Sphinx очень узкоспециализированным решением с необходимостью доработки его напильником во многих случаях. Многие пользователи к тому моменту уже мигрировали на Elasticsearch. Это было очень досадно, так так фундаментальные структуры данных и алгоритмы в Sphinx потенциально и фактически во многих случаях превосходили Elasticsearch по производительности, а SQL, который в Sphinx был развит намного лучше, чем в Elasticsearch даже сейчас, был привлекателен для многих.

Кроме поддержки имеющихся пользователей Sphinx, глобальной целью Manticore была доработка вышеупомянутых и других характеристик, которые позволили бы сделать Manticore Search реальной альтернативой Elasticsearch в большинстве сценариев использования.

Что нам удалось сделать?

Намного более активная разработка

Если посмотреть на статистику гитхаба, то видно, что как только случился форк (середина 2017-го) разработка активизировалась:

За три с половиной года мы выпустили 39 релизов. В 2021 году выпускались примерно каждые два месяца. Так и планируем продолжать.

Репликация

Многие пользователи ждали репликации в Sphinx много лет. Одной из первых крупных фичей, которые мы сделали в Manticore была репликация. Как и всё в Мантикоре, попытались сделать её максимально простой в использовании. Например, чтобы подключиться к кластеру достаточно выполнить команду:

JOIN CLUSTER posts at '127.0.0.1:9312';

, после чего в текущем индексе появятся индексы из кластера. Репликация:

  • синхронная

  • основана на библиотеке Galera, которую так же используют MariaDB и Percona XtraDB.

Auto id

Без auto-id Sphinx / Manticore за исключением редких случаев можно использовать только как приложение к внешнему хранилищу документов и источнику ID. Мы реализовали auto-id на основе алгоритма UUID_SHORT. Гарантируется уникальность до 16 миллионов инсертов в секунду на сервер.

mysql> create table idx(doc text);Query OK, 0 rows affected (0.01 sec)mysql> insert into idx(doc) values('abc def');Query OK, 1 row affected (0.00 sec)mysql> select * from idx;+---------------------+---------+| id                  | doc     |+---------------------+---------+| 1514145039905718278 | abc def |+---------------------+---------+1 row in set (0.00 sec)mysql> insert into idx(doc) values('def ghi');Query OK, 1 row affected (0.00 sec)mysql> select * from idx;+---------------------+---------+| id                  | doc     |+---------------------+---------+| 1514145039905718278 | abc def || 1514145039905718279 | def ghi |+---------------------+---------+2 rows in set (0.00 sec)

Хранилище документов

В Sphinx 2.3.2 и ранее хранить исходные тексты можно было только в строковых атрибутах. Они, как и все атрибуты требуют памяти. Многие пользователи так и делали, расходуя лишнюю память, что на больших объёмах было дорого и чревато другими проблемами. В Manticore мы сделали новый тип данных text, который объединяет полнотекстовую индексацию и хранение на диске с отложенным чтением (т.е. значение достаётся на самой поздней стадии выполнения запроса). По "stored" полям нельзя фильтровать, сортировать, группировать. Они просто лежат на диске в сжатом виде, чтоб не было нужды хранить их в mysql/hbase и прочих БД, когда это совсем не нужно. Фича оказалась очень востребованной. Теперь Manticore не требует ничего, кроме самой себя для реализации поискового приложения.

mysql> desc idx;+-------+--------+----------------+| Field | Type   | Properties     |+-------+--------+----------------+| id    | bigint |                || doc   | text   | indexed stored |+-------+--------+----------------+2 rows in set (0.00 sec) 

Real-time индексы

В Sphinx 2.3.2 и ранее многие пользователи опасались использовать real-time индексы, т.к. это часто приводило к крэшам и другим побочным эффектам. Большую часть известных багов и недоработок мы устранили, над некоторыми оптимизациями ещё работаем (в основном связанным с автоматически OPTIMIZE). Но можно с уверенностью сказать, что real-time индексы можно использовать в проде, что многие пользователи и делают. Из наиболее заметных фичей добавили:

  • многопоточность: поиск в нескольких дисковых чанках одного real-time индекса делается параллельно

  • оптимизации OPTIMIZE: по умолчанию чанки мерджатся не до одного, а до кол-ва ядер на сервере * 2 (регулируется через опцию cutoff)

Работаем над автоматическим OPTIMIZE.

charset_table = cjk, non_cjk

Раньше, если требовалась поддержка какого-то языка кроме русского и английского часто нужно было поддерживать длинные массивы в charset_table. Это было неудобно. Мы попытались это упростить, собрав всё, что может понадобиться во внутренние массивы с алиасами non_cjk (для большинства языков) и cjk (для китайского, корейского и японского). И сделали non_cjk настройкой по умолчанию. Теперь можно без проблем искать по русскому, английскому и, скажем, турецкому:

mysql> create table idx(doc text);Query OK, 0 rows affected (0.01 sec)mysql> insert into idx(doc) values('abc абв renim');Query OK, 1 row affected (0.00 sec)mysql> select * from idx where match('abc');+---------------------+----------------------+| id                  | doc                  |+---------------------+----------------------+| 1514145039905718280 | abc абв renim      |+---------------------+----------------------+1 row in set (0.00 sec)mysql> select * from idx where match('абв');+---------------------+----------------------+| id                  | doc                  |+---------------------+----------------------+| 1514145039905718280 | abc абв renim      |+---------------------+----------------------+1 row in set (0.00 sec)mysql> select * from idx where match('ogrenim');+---------------------+----------------------+| id                  | doc                  |+---------------------+----------------------+| 1514145039905718280 | abc абв renim      |+---------------------+----------------------+1 row in set (0.00 sec)

Официальный Docker image

Выпустили и поддерживаем официальный docker image . Запустить Manticore теперь можно за несколько секунд где угодно, если там есть докер:

  ~ docker run --name manticore --rm -d manticoresearch/manticore && docker exec -it manticore mysql && docker stop manticore525aa92aa0bcef3e6f745ddeb11fc95040858d19cde4c9118b47f0f414324a79mysql> create table idx(f text);mysql> desc idx;+-------+--------+----------------+| Field | Type   | Properties     |+-------+--------+----------------+| id    | bigint |                || f     | text   | indexed stored |+-------+--------+----------------+

Кроме того, в manticore:dev всегда лежит самая свежая development версия.

Репозиторий для пакетов

Релизы и свежие development версии автоматически попадают в https://repo.manticoresearch.com/

Оттуда же можно легко установить Manticore через YUM и APT. Homebrew тоже поддерживаем, как и сборку под Мак в целом.

NLP: обработка естественного языка

По NLP сделали такие улучшения:

  • сегментация китайского с помощью библиотеки ICU

  • стоп-слова для большинства языков из коробки:

    mysql> create table idx(doc text) stopwords='ru';Query OK, 0 rows affected (0.01 sec)mysql> call keywords('кто куда пошёл - я не знаю', 'idx');+------+------------+------------+| qpos | tokenized  | normalized |+------+------------+------------+| 3    | пошел      | пошел      || 6    | знаю       | знаю       |+------+------------+------------+2 rows in set (0.00 sec)
    
  • поддержка Snowball 2.0 для стемминга большего количества языков

  • более лёгкая в плане синтаксиса подсветка:

    mysql> insert into idx(doc) values('кто куда пошёл - я не знаю');Query OK, 1 row affected (0.00 sec)mysql> select highlight() from idx where match('пошёл');+------------------------------------------------------+| highlight()                                          |+------------------------------------------------------+| кто куда <b>пошёл</b> - я не знаю                    |+------------------------------------------------------+1 row in set (0.00 sec)
    

Новая многозадачность

Упростили многозадачность за счёт использования корутин. Кроме того, что код стал намного проще и надёжнее, для улучшения распараллеливания теперь не нужно выставлять dist_threads и думать какое значение поставить какому индексу, чтобы поиск по нему максимально оптимально распараллеливался. Теперь есть глобальная настройка threads, которая по умолчанию равна количеству ядер на сервере. В большинстве случаев для оптимальной работы вообще трогать ничего не нужно.

Поддержка OR в WHERE

В Sphinx 2/3 нельзя легко фильтровать по атрибутам через ИЛИ. Это неудобно. Сделали такую возможность в Manticore:

mysql> select i, s from t where i = 1 or s = 'abc';+------+------+| i    | s    |+------+------+|    1 | abc  ||    1 | def  ||    2 | abc  |+------+------+3 rows in set (0.00 sec)Sphinx 3:mysql> select * from t where i = 1 or s = 'abc';ERROR 1064 (42000): sphinxql: syntax error, unexpected OR, expecting $end near 'or s = 'abc''

Поддержка протокола JSON через HTTP

SQL - это круто. Мы любим SQL. И в Sphinx / Manticore всё, что касается синтаксиса запросов можно сделать через SQL. Но бывают ситуации, когда оптимальным решением является использование JSON-интерфейса, как, например, в Elasticsearch. SQL крут для дизайна запроса, а используя JSON сложный запрос часто легче интегрировать с приложением.

Кроме того, HTTP сам по себе позволяет делать интересные вещи: использовать внешние HTTP load balancer'ы, proxy, что позволяет довольно-таки несложно реализовать аутентификацию, RBAC и прочее.

Новые клиенты для большего количества языков

Ещё легче может быть использовать клиент для конкретного языка программирования. Мы реализовали новые клиенты для php, python, java, javascript, elixir, go. Большинство из них основаны на новом JSON-интерфейсе и их код генерируется автоматически, что позволяет добавлять новые фичи в клиенты намного быстрее.

Поддержка HTTPS

Сделали поддержку HTTPS из коробки. Светить Мантикорой наружу всё ещё не стоит, т.к. встроенных средств аутентификации нет, но гонять трафик от клиента к серверу по локальной сети теперь можно безопасно. SSL для mysql-интерфейса тоже поддерживается.

Поддержка FEDERATED

Вместо SphinxSE (встроенный в mysql движок, который позволяет теснее интегрировать Sphinx/Manticore с mysql) теперь можно использовать FEDERATED.

Поддержка ProxySQL

И ProxySQL тоже можно.

RT mode

Одним из главных изменений стало то, что мы сделали императивный (т.е. через CREATE/ALTER/DROP table) способ работы со схемой данных Manticore дефолтным. Как видно из примеров выше - о конфиге, source, index и прочем речи теперь в большинстве случаев не идёт. С индексами можно полноценно работать без необходимости править конфиг, рестартить инстанс, удалять файлы real-time индексов и т.д. Схема данных теперь отделена от настроек сервера полностью. И это режим по-умолчанию.

Но декларативный режим всё так же поддерживается. Мы не считаем его рудиментом и не планируем от него избавляться. Как с Kubernetes можно общаться и через yaml-файлы и через конкретные команды, так и c Manticore:

  • можно описать всё в конфиге и пользоваться возможностью лёгкого его портирования и т.д.,

  • а можно создавать индексы на лету

Смешивать использование режимов нельзя и не планируется. Чтобы как-то обозначить режимы, мы назвали декларативный режим (как в Sphinx 2) plain mode, а императивный режим RT mode (real-time mode).

Percolate index

Решили задачу обратного поиска, когда в индексе лежат запросы, а документы поступают в запросах. Работает шустро по сравнению с эластиком.

mysql> create table t(f text, j json) type='percolate';mysql> insert into t(query,filters) values('abc', 'j.a=1');mysql> call pq('t', '[{"f": "abc def", "j": {"a": 1}}, {"f": "abc ghi"}, {"j": {"a": 1}}]', 1 as query);+---------------------+-------+------+---------+| id                  | query | tags | filters |+---------------------+-------+------+---------+| 8215503050178035714 | abc   |      | j.a=1   |+---------------------+-------+------+---------+

Новая удобная документация

Полностью переработали документацию - https://manual.manticoresearch.com

Для ключевой функциональности есть примеры для большинства поддерживаемых клиентов. Поиск работает так же через Manticore. Удобная умная подсветка в результатах поиска. HTTP примеры можно копировать одним кликом прямо в в виде команды curl с параметрами. Кроме того, специально зарегистрировали короткий домен mnt.cr, чтоб можно было очень быстро найти информацию по какой-то настройке, детали по которой подзабылись, например: mnt.cr/proximity , mnt.cr/quorum, mnt.cr/percolate

Интерактивные курсы

Сделали платформу для интерактивных курсов https://play.manticoresearch.com и, собственно, сами курсы, с помощью которых можно ознакомиться с работой Manticore прямо из браузера, вообще ничего не устанавливая. Проще уже, по-моему, некуда, хотя мы стараемся всё сделать максимально просто для пользователя.

Github в качестве bug tracker'а

В качестве публичного bug/task tracker'а используем Гитхаб.

Есть же Sphinx 3

В декабре 2017 года был выпущен Sphinx 3.0.1. До октября 2018 было сделано ещё три релиза и ещё один в июле 2020-го (3.3.1, последняя версия на момент февраля 2021). Появилось много интересных фич, в том числе вторичные индексы и интеграция с machine learning, которая позволяет решать некоторые задачи, требующие подходов машинного обучения. В чём же проблема? На кой вообще нужен этот ваш Manticore? Одной из причин является то, что к сожалению, ни первый релиз Sphinx 3, ни последний на текущий момент не open source:

  • в том смысле, что код Sphinx 3 недоступен

  • а также в более широком понимании open source. Cказано, что с версии 3.0 Sphinx теперь доступен под лицензией "delayed FOSS". Что именно это за лицензия и где можно с ней ознакомиться не разглашается. Непонятно:

    • то ли оно всё ещё GPLv2 (т.е. под "delayed FOSS" подразумевается отложенный GPLv2), т.к. код вероятно основан на Sphinx 2, который GPLv2 (как и Manticore). Но где тогда исходный код?

    • то ли нет, т.к. никакой лицензии к бинарным файлам не прилагается. И основан ли код вообще на Sphinx 2, который GPLv2? И действуют ли ограничения GPLv2? Или можно уже распространять исполняемые файлы sphinx 3 без оглядки на GPL? "delayed FOSS" за отсутствием какого-либо текста лицензии это ведь не запрещает.

  • релизов не было с июля 2020. Багов много. Когда они будут фикситься?

Вопросов много. Ответов нет. Всё это делает крайне затруднительным использование Sphinx 3 людьми и компаниями, которым важна юридическая сторона вопроса и стабильность проекта. Не все имеют возможность вкладывать время своих сотрудников во что-то настолько непонятное.

В общем и целом, Sphinx 3 сейчас можно воспринимать как проприетарное решение для ограниченного круга пользователей с ограниченными целями. Очень жаль, что open source мир потерял Sphinx. Можно только надеяться, что в будущем что-то поменяется.

Побенчим?

Дано:

  • датасет, состоящий из 1M+ комментов с HackerNews с численными атрибутами

  • plain index из этого датасета. Размер файлов индекса около 1 гигабайта

  • набор разнообразных запросов (132 запроса) от различных вариантов полнотекстового поиска до фильтрации и группировки по атрибутам

  • запуск в докере в равнозначных условиях на пустой железной машине с различными ограничениями по памяти (через cgroups)

  • запрос через SQL протокол клиентом mysqli из PHP-скрипта

  • перед каждым запросом перезапуск докеров с предварительной очисткой всех кэшей, далее 5 попыток, в статистику попадает самая быстрая

Результаты:

Лимит памяти 100 мегабайт:

500 мегабайт:

1000 мегабайт:

Будущее Manticore Search

Итак, у нас уже есть активно разрабатываемый продукт под всем понятной open source лицензией GPLv2 с репликацией, auto-id, нормально работающими real-time индексами, JSON интерфейсом, адекватной документацией, курсами и многим ещё чем. Что дальше? Роадмэп у нас такой:

Новый Manticore engine

С начала 2020 года мы работаем над библиотекой хранения и обработки данных в поколоночном виде с индексацией по умолчанию (в отличие от, скажем, clickhouse). Для пользователей Manticore / Sphinx это решит проблемы:

  • необходимости большого объёма свободной памяти при большом количестве документов и атрибутов

  • не всегда быстрой группировки

У нас уже готова альфа-версия и вот, например, какие получаются результаты, если использовать её в Manticore Search и сравнить её производительность на том же датасете с Elasticsearch (исключая полнотекстовые запросы, т.е. в основном группировочные запросы):

Но работы всё ещё много. Библиотека будет доступна под permissive open source лицензией и её можно будет использовать как в Manticore Search, так и в других проектах (может быть и в Sphinx).

Auto OPTIMIZE

Доделываем, надеемся выложить в феврале.

Интеграция с Kibana

Так как с новым Manticore engine можно делать больше аналитики, то встаёт вопрос как это лучше визуализировать. Grafana - круто, но для полнотекста надо что-то придумывать. Kibana - тоже норм, многие знают и используют. У нас готова альфа версия интеграции Manticore с Кибаной. Работать будет прямо из коробки. Отлаживаем.

Интеграция с Logstash

JSON протокол у нас уже есть. В планах немного доработать его методы PUT и POST до совместимости с Elasticsearch в плане INSERT/REPLACE запросов. Кроме того, планируем разработку создания индекса на лету на основе первых вставленных в него документов.

Всё это позволит писать данные в Manticore Search вместо Elasticsearch из Logstash, Fluentd, Beats и им подобных.

Автоматическое шардирование

Проектируем. Уже понимаем с какими сложностями придётся столкнуться и более-менее как их решать. Планируем на второй квартал.

Альтернатива Logstash?

Logstash требует от пользователя потратить много времени для того, чтобы начать обрабатывать новый кастомный тип лога вашего приложения. А если добавите какую-нибудь строчку, то правила парсинга придётся поменять. Мы разрабатываем систему автоматического парсинга логов, которая позволяет разбить лог на составляющие с достаточной точностью для того, чтобы использовать полученные структурированные документы для фильтрации по полям, сортировки, группировки, а не только полнотекстового поиска по логам. Альфа-версия тоже имеется и в принципе задачу решает, но работы по окультуриванию её предстоит ещё много.


Если вам небезразличен проект, присоединяйтесь:

Будем рады любой критике. Оревуар!

Подробнее..

Перевод Пишем движок полнотекстового поиска на Go

14.09.2020 18:23:30 | Автор: admin
Полнотекстовый поиск один из тех инструментов, которые мы используем практически каждый день, когда ищем какую-то информацию в интернете. Full-Text Search (FTS) это метод поиска текста в коллекции документов. Документ может ссылаться на веб-страницу, газетную статью, сообщение электронной почты или любой структурированный текст.

Сегодня мы собираемся написать собственный движок FTS. К концу этой статьи он сможет выполнять поиск по миллионам документов менее чем за миллисекунду. Начнём с простых поисковых запросов, таких как Выдать все документы со словом cat, а потом расширим движок для поддержки более сложных логических запросов.

Примечание: самым известным движком полнотекстового поиска является Lucene (а также Elasticsearch и Solr, построенные на его основе).

Зачем нужен FTS


Перед тем, как писать код, вы можете спросить: А нельзя ли просто использовать grep или цикл с проверкой каждого документа на вхождение искомого слова? Да, можно. Но это не всегда лучшая идея.

Корпус


Будем искать фрагменты аннотаций из англоязычной Википедии. Последний дамп доступен по адресу dumps.wikimedia.org. На сегодняшний день размер файла после распаковки составляет 913МБ. В XML-файле более 600 тыс. документов.

Пример документа:

<title>Wikipedia: Kit-Cat Klock</title><url>https://en.wikipedia.org/wiki/Kit-Cat_Klock</url><abstract>The Kit-Cat Klock is an art deco novelty wall clock shaped like a grinning cat with cartoon eyes that swivel in time with its pendulum tail.</abstract>

Загрузка документов


Сначала нужно загрузить все документы из дампа, используя очень удобный встроенный пакет encoding/xml:

import (    "encoding/xml"    "os")type document struct {    Title string `xml:"title"`    URL   string `xml:"url"`    Text  string `xml:"abstract"`    ID    int}func loadDocuments(path string) ([]document, error) {    f, err := os.Open(path)    if err != nil {        return nil, err    }    defer f.Close()    dec := xml.NewDecoder(f)    dump := struct {        Documents []document `xml:"doc"`    }{}    if err := dec.Decode(&dump); err != nil {        return nil, err    }    docs := dump.Documents    for i := range docs {        docs[i].ID = i    }    return docs, nil}

Каждому документу присваивается уникальный ID. Для простоты первому загруженному документу присваивается ID=0, второму ID=1 и так далее.

Первая попытка


Поиск контента


Теперь у нас все документы загружены в память, попробуем найти те, в которых упоминаются кошки. Сначала пройдёмся по всем документам и проверим их на подстроку cat:

func search(docs []document, term string) []document {    var r []document    for _, doc := range docs {        if strings.Contains(doc.Text, term) {            r = append(r, doc)        }    }    return r}

На моём ноутбуке поиск занимает 103мс не так уж плохо. Если выборочно проверить несколько документов из выдачи, то можно заметить, что функция выдаёт соответствие на слова caterpillar и category, но не на Cat с заглавной буквой C. Это не совсем то, что мы ищем.

Прежде чем продолжать, нужно исправить две вещи:

  • Сделать поиск нечувствительным к регистру (чтобы выдача включала и Cat).
  • Учесть границы слов, а не подстроки (чтобы в выдаче не было слов вроде caterpillar и communication).

Поиск с помощью регулярных выражений


Одно из очевидных решений, которое решает обе проблемы регулярные выражения.

В данном случае нам нужно (?i)\bcat\b:

  • (?i) означает нечувствительность регулярного выражения к регистру
  • \b указывает на соответствие границам слов (место, где с одной стороны есть символ, а с другой стороны нет)

Но теперь поиск занял больше двух секунд. Как видите, система начала тормозить даже на скромном корпусе из 600тыс. документов. Хотя такой подход легко реализовать, он не очень хорошо масштабируется. По мере увеличения набора данных нужно сканировать всё больше документов. Времення сложность такого алгоритма линейна, то есть количество документов для сканирования равно общему количеству документов. Если бы у нас было 6 миллионов документов вместо 600 тысяч, поиск занял бы 20 секунд. Придётся придумать что-то получше.

Инвертированный индекс


Чтобы ускорить поисковые запросы, мы предварительно обработаем текст и построим индекс.

Ядром FTS является структура данных, которая называется инвертированный индекс. Он связывает каждое слово с документами, содержащими это слово.

Пример:

documents = {    1: "a donut on a glass plate",    2: "only the donut",    3: "listen to the drum machine",}index = {    "a": [1],    "donut": [1, 2],    "on": [1],    "glass": [1],    "plate": [1],    "only": [2],    "the": [2, 3],    "listen": [3],    "to": [3],    "drum": [3],    "machine": [3],}

Ниже приведён реальный пример инвертированного индекса. Это указатель в книге, где термин сопровождается номерами страниц:



Анализ текста


Прежде чем приступить к построению индекса, нужно разбить исходный текст на список слов (токенов), пригодных для индексации и поиска.

Анализатор текста состоит из токенизатора и нескольких фильтров.



Токенизатор


Токенизатор это первый шаг в анализе текста. Его задача преобразовать текст в список токенов. Наша реализация разбивает текст на границах слов и удаляет знаки препинания:

func tokenize(text string) []string {    return strings.FieldsFunc(text, func(r rune) bool {        // Split on any character that is not a letter or a number.        return !unicode.IsLetter(r) && !unicode.IsNumber(r)    })}

> tokenize("A donut on a glass plate. Only the donuts.")["A", "donut", "on", "a", "glass", "plate", "Only", "the", "donuts"]

Фильтры


В большинстве случаев недостаточно простого преобразования текста в список токенов. Чтобы облегчить индексацию и поиск, потребуется дополнительная нормализация.

Строчные буквы


Чтобы поиск был нечувствителен к регистру, фильтр строчных букв преобразует токены в нижний регистр. Слова cAt, Cat и caT нормализуются до формы cat. Позже при обращении к индексу мы также нормализуем в нижний регистр и поисковые запросы, так что поисковый запрос cAt найдёт слово Cat.

Удаление общеупотребительных слов


Почти любой англоязычный текст содержит общеупотребительные слова, такие как a, I, The или be. Они называются стоп-словами и присутствуют почти во всех документах, так что их следует удалить.

Нет никакого официального списка стоп-слов. Давайте исключим топ-10 по списку OEC. Не стесняйтесь дополнять его:

var stopwords = map[string]struct{}{ // I wish Go had built-in sets.    "a": {}, "and": {}, "be": {}, "have": {}, "i": {},    "in": {}, "of": {}, "that": {}, "the": {}, "to": {},}func stopwordFilter(tokens []string) []string {    r := make([]string, 0, len(tokens))    for _, token := range tokens {        if _, ok := stopwords[token]; !ok {            r = append(r, token)        }    }    return r}

> stopwordFilter([]string{"a", "donut", "on", "a", "glass", "plate", "only", "the", "donuts"})["donut", "on", "glass", "plate", "only", "donuts"]

Стемминг


Из-за грамматических правил в документах встречаются разные формы слов. Стемминг сводит их к основной форме. Например, fishing, fished и fisher сводятся к основной форме fish.

Реализация стемминга нетривиальная задача, она не рассматривается в этой статье. Возьмём один из существующих модулей:

import snowballeng "github.com/kljensen/snowball/english"func stemmerFilter(tokens []string) []string {    r := make([]string, len(tokens))    for i, token := range tokens {        r[i] = snowballeng.Stem(token, false)    }    return r}

> stemmerFilter([]string{"donut", "on", "glass", "plate", "only", "donuts"})["donut", "on", "glass", "plate", "only", "donut"]

Примечание: Стеммеры не всегда работают корректно. Например, некоторые могут сократить airline до airlin.

Сборка анализатора


func analyze(text string) []string {    tokens := tokenize(text)    tokens = lowercaseFilter(tokens)    tokens = stopwordFilter(tokens)    tokens = stemmerFilter(tokens)    return tokens}

Токенизатор и фильтры преобразуют предложения в список токенов:

> analyze("A donut on a glass plate. Only the donuts.")["donut", "on", "glass", "plate", "only", "donut"]

Токены готовы к индексации.

Построение индекса


Вернёмся к инвертированному индексу. Он сопоставляет каждое слово с идентификаторами документов. Для хранения карты хорошо подходит встроенный тип данных map. Ключом будет токен (строка), а значением список идентификаторов документов:

type index map[string][]int

В процессе построения индекса происходит анализ документов и добавление их идентификаторов в карту:

func (idx index) add(docs []document) {    for _, doc := range docs {        for _, token := range analyze(doc.Text) {            ids := idx[token]            if ids != nil && ids[len(ids)-1] == doc.ID {                // Don't add same ID twice.                continue            }            idx[token] = append(ids, doc.ID)        }    }}func main() {    idx := make(index)    idx.add([]document{{ID: 1, Text: "A donut on a glass plate. Only the donuts."}})    idx.add([]document{{ID: 2, Text: "donut is a donut"}})    fmt.Println(idx)}

Всё работает! Каждый токен на карте ссылается на идентификаторы документов, содержащих этот токен:

map[donut:[1 2] glass:[1] is:[2] on:[1] only:[1] plate:[1]]

Запросы


Для запросов к индексу применим тот же токенизатор и фильтры, которые использовали для индексации:

func (idx index) search(text string) [][]int {    var r [][]int    for _, token := range analyze(text) {        if ids, ok := idx[token]; ok {            r = append(r, ids)        }    }    return r}

> idx.search("Small wild cat")[[24, 173, 303, ...], [98, 173, 765, ...], [[24, 51, 173, ...]]

И теперь, наконец, мы можем найти все документы, в которых упоминаются кошки. Поиск по 600тыс. документов занял меньше миллисекунды (18мкс)!

При использовании инвертированного индекса времення сложность поискового запроса линейна по отношению к числу поисковых токенов. В приведённом выше примере запроса, кроме анализа входного текста, выполняется всего три поиска по карте.

Логические запросы


Предыдущий запрос вернул несвязанный список документов для каждого токена. Но мы обычно ожидаем, что поиск по фразе small wild cat выдаёт список результатов, которые содержат одновременно small, wild и cat. Следующий шаг вычислить пересечение между списками. Таким образом, мы получим список документов, соответствующих всем токенам.



К счастью, идентификаторы в нашем инвертированном индексе вставляются в порядке возрастания. Поскольку ID отсортированы, можно вычислить пересечение между списками в линейном времени. Функция intersection одновременно выполняет итерацию двух списков и собирает идентификаторы, которые присутствуют в обоих:

func intersection(a []int, b []int) []int {    maxLen := len(a)    if len(b) > maxLen {        maxLen = len(b)    }    r := make([]int, 0, maxLen)    var i, j int    for i < len(a) && j < len(b) {        if a[i] < b[j] {            i++        } else if a[i] > b[j] {            j++        } else {            r = append(r, a[i])            i++            j++        }    }    return r}

Обновленный search анализирует заданный текст запроса, ищет токены и вычисляет заданное пересечение между списками ID:

func (idx index) search(text string) []int {    var r []int    for _, token := range analyze(text) {        if ids, ok := idx[token]; ok {            if r == nil {                r = ids            } else {                r = intersection(r, ids)            }        } else {            // Token doesn't exist.            return nil        }    }    return r}

В дампе Википедии только два документа, которые одновременно содержат слова small, wild и cat:

> idx.search("Small wild cat")130764  The wildcat is a species complex comprising two small wild cat species, the European wildcat (Felis silvestris) and the African wildcat (F. lybica).131692  Catopuma is a genus containing two Asian small wild cat species, the Asian golden cat (C. temminckii) and the bay cat.

Поиск работает как положено!

Кстати, я впервые узнал о катопумах, вот одна из них:



Выводы


Итак, мы сделали движок для полнотекстового поиска. Несмотря на свою простоту, он может стать прочной основой для более продвинутых проектов.

Я не упомянул о многих аспектах, которые могут значительно улучшить производительность и сделать поиск удобнее. Вот некоторые идеи для дальнейших улучшений:

  • Добавить логические операторы OR и NOT.
  • Хранить индекс на диске:
    • Восстановление индекса при каждом перезапуске приложения занимает некоторое время.
    • Большие индексы могут не поместиться в памяти.
  • Поэкспериментировать с памятью и оптимизированными для CPU форматами данных для хранения наборов ID. Взглянуть на Roaring Bitmaps.
  • Индексация нескольких полей документа.
  • Сортировать результаты по релевантности.

Весь исходный код опубликован на GitHub.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru