Простой и компактный поисковый движок для быстрого поиска по небольшим корпусам текстов
$ python -m cli.app index config.yaml # индексируем движок на корпусе
$ python -m cli.app search config.yaml # выполняем поиск через CLI интерфейс
>>> ...
$ streamlit run streamlit_/app.py # выполняем поиск через Streamlit интерфейс
>>> ...
$ python3.8 -m venv .venv
$ source .venv/bin/activate
$ pip install pip --upgrade
$ pip install setuptools --upgrade
$ pip install wheel
$ pip install -r requirements.txt
- Простое использование — всего две операции с простым интерфейсом
- Простая разработка — легко добавить новый тип движка или корпуса
- Просто понять — мало компонентов и мало кода
- Поведение настраивается конфигом
- Эффективная по времени и памяти реализация на базе NumPy, SciPy и PyTorch
- Поддержка разных графических интерфейсов
Поиск с ранжированием по метрике Okapi BM25 в матричном виде.
corpus: # корпус для индексации
type: "string" # тип загрузчика корпуса
params: # параметры загрузчика корпуса
...
engine: # поисковый движок
dst: "string" # путь для сохранения индекса
type: "bm25" # тип поискового движка
params: # параметры поискового движка
preprocessor: # препроцессор
type: "string" # тип препроцессора
params: # параметры препроцессора
...
Поиск по мешку слов в матричном виде.
corpus: # корпус для индексации
type: "string" # тип загрузчика корпуса
params: # параметры загрузчика корпуса
...
engine: # поисковый движок
dst: "string" # путь для сохранения индекса
type: "bow" # тип поискового движка
params: # параметры поискового движка
preprocessor: # препроцессор
type: "string" # тип препроцессора
params: # параметры препроцессора
...
vectorizer: # векторизатор из sklearn
type: "string" # тип векторизатора
params: # параметры векторизатора
...
Поиск по контекстным векторам из предобученных моделей.
corpus: # корпус для индексации
type: "string" # тип загрузчика корпуса
params: # параметры загрузчика корпуса
...
engine: # поисковый движок
dst: "string" # путь для сохранения индекса
type: "ctx" # тип поискового движка
params: # параметры поискового движка
checkpoint: "string" # чекпоинт модели из transformers
Интерактивная сессия в терминале.
Позволяет искать по загруженному на старте сессии движку и индексу.
$ python -m cli.app search config.yaml
Веб-интерфейс на базе Streamlit.
Позволяет динамически выбирать движок и индекс и искать по нему.
$ streamlit run streamlit_/app.py
Примечание
Предложенную архитектуру не следует воспринимать как эталон или единственно верный вариант.
Это всего лишь один из многих возможных вариантов.
Зачем вообще возиться с архитектурой?
В первую очередь для того, чтобы обеспечить максимально комфортные условия для эффективной поддержки и развития приложения на максимально долгий период.
Выстраивание архитектуры не поможет вам сделать так, чтобы код работал согласно задумке, но поможет сделать так, чтобы этот код можно было поддерживать и развивать.
В основе архитектуры лежат два компонента — поисковый движок и корпус.
Почему именно они?
Они обеспечивают главные функции нашего приложения — индексацию и поиск.
Корпус предоставляет доступ к документам для индексации, а движок — к операциям для индексации и поиска.
Что нужно уметь корпусу?
- Загрузить документы для индексации по разной логике
- Дать возможность узнать количество документов
- Дать возможность итерации по документам
- Дать возможность взять документ по айди
Что нужно уметь движку?
- Создать экземпляр движка
- Выполнить индексацию корпуса
- Сохранить индекс на диск для дальнейшего переиспользования
- Загрузить экземпляр движка из сохраненного индекса
- Выполнить поиск по индексу
Перечисленных выше операций достаточно, чтобы реализовать на их базе весь нужный нам функционал.
Как мы обеспечим комфортное развитие приложения?
Для начала обозначим, что мы хотим сделать удобным для изменений прежде всего.
Мы хотим иметь возможность легко добавлять новые типы поисковых движков и новую логику загрузки корпуса.
На деле это значит, что нам нужно сделать так, чтобы при изменении или расширении этих двух якорных компонентов, не ломались все остальные компоненты приложения.
Для этого зафиксируем интерфейс для движка и корпуса, через который все остальные компоненты приложения будут взаимодействовать с ними.
Так зависимые компоненты получат нечто вроде гаранта, что они не сломаются при добавлении или изменении внутренней логики тех компонентов, от которых они зависят.
Можно сказать, что так мы сделаем связи между компонентами стабильными, благодаря чему сможем изменять или добавлять отдельные компоненты, не боясь, что от этого как-то случайно сломаются или изменятся другие компоненты.
Что еще можно сделать?
Сразу на старте заложим еще несколько механизмов:
- Управление поведением с помощью конфига
- Разделение логики компонентов от логики создания их экземпляров
- Разделение логики приложения от логики отображения на интерфейсе
Чем поможет конфиг?
Внедрение конфига поможет управлять поведением приложения в понятном и стабильном виде.
С помощью конфига можно управлять большим количеством параметров и описывать логику практически любой сложности.
А еще конфиг помогает сделать запуск приложения проще и понятнее.
Чем поможет отделение логики компонента от логики его создания?
Речь о создании экземпляров классов.
Идея в том, чтобы не смешивать в одном классе логику его операций (его методы) и логику создания его экземпляров, а вынести логику создания экземпляров в отдельный класс или функцию.
Во-первых, часто получается так, что логика создания экземпляра класса не пересекается с логикой его операций, а значит нет особенного смысла связывать их вместе.
Во-вторых, иногда логика создания экземпляра может быть очень объемной или сложной, и ее определение внутри класса сделает его громоздким.
В нашем случае это, конечно, загрузка корпусов и загрузка движков из чекпоинтов.
В-третьих, подобно интерфейсам, такой подход поможет обеспечить стабильность между компонентами.
Суть в том, что так есть только одна "точка входа" для создания компонента, а значит все зависимые компоненты вынуждены использовать только ее.
Благодаря этому можно относительно независимо изменять как тот компонент, экземпляр которого создается, так и зависящие от него компоненты.
Этот прием соответствует паттерну проектирования Factory (фабрика).
Особенно этот паттерн полезен, когда логика создания экземпляров объемна и нетривиальна.
Помимо описанного выше, еще появляются бонусы:
- Более чистый и компактный код класса
- Возможность менять логику создания прямо в рантайме (а не только на старте приложения)
- Возможность более безопасного и легкого расширения параметров класса
Чем поможет отделение логики от отображения?
Такое разделение внесет стабильность в связь между логикой и отображением.
Идея в том, что для того, чтобы отобразить результат поиска, не нужно знать в деталях о том, как работает поиск.
И наоборот, для того, чтобы провести ранжирование, не надо думать о том, как оно будет показано пользователю дальше.
Соблюдая этот простой принцип, мы сможем добавлять новые интерфейсы отображения без необходимости каких-либо изменений в логике поиска или индексации.
То же самое, конечно, справедливо и в обратную сторону.
Что общего в принципах выше?
Основная идея в том, чтобы постараться сделать взаимодействие между компонентами стабильным, прозрачным и предсказуемым и не создавать жесткие связи между компонентами.
Эффективный механизм для достижения этого — построение взаимодействия между компонентами, опираясь на их зафиксированные интерфейсы, а не на произвольные детали их логики.
Благодаря этому появляется возможность изменять компоненты независимо друг от друга без опасений случайно сломать какой-то другой компонент.
- Основные компоненты
- Корпус
- Поисковый движок
- Препроцессор
- Выделенная публичная точка входа в приложение
- Фиксированный интерфейс для каждого компонента
ICorpusBuilder:
загрузчик корпусаIEngine:
поисковый движокIPreprocessor:
препроцессор текста
- Все взаимодействие между компонентами только через фиксированные интерфейсы
- Приложение выполняет только то, что предоставляет публичная точка входа
- Загрузить корпус по конфигу
- Загрузить движок по конфигу
- Получить документ по айди
- Индексировать движок по конфигу и сохранить результат
- Для создания экземпляров основных компонентов используем фабрики
- Используем структуры данных для основных типов объектов
Document:
индексируемый документScore:
результат ранжирования документаCorpus:
корпус документов
- Поведение приложения и пути к данным управляются YAML конфигом
- Графические интерфейсы реализованы отдельно от кода приложения и используют только то, что предоставлено публичной точкой входа
assets:
разные сопровождающие файлыconfigs:
конфигиdata:
корпусы
cli:
графический интерфейс на базе терминалаapp.py:
точка входа в интерфейсmvc.py:
логика интерфейса
streamlit_:
графический интерфейс на базе Streamlitapp.py:
точка входа в интерфейсapi.py:
загрузка логики приложения
tinysearch:
приложениеcorpus:
корпус и все связанноеfactory.py:
создание корпусаmulti_file.py:
загрузчик корпуса из многих файловsingle_file.py:
загрузчик корпуса из одного файла
engine:
поисковые движки и все связанноеfactory.py:
создание поискового движкаbm25.py:
движок на базе метрики BM25bow.py:
движок на базе мешка словctx.py:
движок на базе контекстных векторовcommon:
общий вспомогательный функционалrank.py:
ранжирование результатов поиска
preprocessor:
препроцессоры и все связанноеfactory.py:
создание препроцессораsimple.py:
простой препроцессор
api.py:
публичная точка входа в приложениеdata.py:
структуры данныхinterface.py:
интерфейсы основных компонентов
.gitignore:
файлы для игнорирования гитомREADME.md:
ридмиrequirements.txt:
зависимости