Делаем жизнь легче: быстрый поиск в django и postgresql с помощью search_vector

от автора

Привет, меня зовут Таня и я backend-разработчик в ИдаПроджект

Сегодня хочу рассказать о полнотекстовом поиске — как это все работает в django, а как в postgres, и откуда вообще взялось. 

Современные компании ежедневно сталкиваются с разной текстовой информацией. Эффективный поиск не только ускоряет доступ к нужным данным, но и повышает продуктивность, снижает затраты и открывает новые возможности для анализа и принятия решений. 

Новичкам важно понять, как полнотекстовый поиск облегчает обработку данных и извлечение информации. Для тех, кто уже знаком с Django и PostgreSQL, статья станет экскурсом в полнотекстовый поиск, а заодно поможет интегрировать его в проекты. 

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

Оглавление

Откуда взялся полнотекстовый поиск?

Что такое SearchVector и триграммы

Как оно работает в postgresql?

А что с индексами?

Как оно работает в django?

А что под нагрузкой, м?

Методология тестирования

Заключение

Источники

Откуда взялся полнотекстовый поиск?

Начнем с определения.

Полнотекстовый поиск — технология поиска текстовой информации в электронных документах, где поиск выполняется по содержимому текста, а не только по метаданным или заголовкам. Такой подход стал необходимым с увеличением объемов данных и потребности извлекать нужную информацию быстро и эффективно. Давайте рассмотрим подробнее историческую основу и развитие этой технологии.

В 50-60-е годы ученые начали исследовать способы хранения и поиска текстовой информации. В тот период появились базовые подходы к информационному поиску. Полнотекстовый поиск не использовался широко из-за ограничений вычислительных мощностей и недостатков в теории и методах обработки текстовой информации.

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

В 80-90-е годы активное внимание уделялось разработке алгоритмов ранжирования и улучшения качества поиска. В этот период был представлен алгоритм TF-IDF (Term Frequency-Inverse Document Frequency), который значительно улучшил релевантность поиска. Алгоритм оценивает важность каждого термина в документе, основываясь на частоте его появления и общем количестве документов.

В это же время развивалась PostgreSQL, где уже существовали базовые возможности работы с текстом, однако они не были оптимизированы для полнотекстового поиска. Пользователи могли искать текст через простые операторы LIKE и POSIX, но такие методы оказались неэффективными для больших объемов данных.

С распространением Интернета в конце 90-х и начале 2000-х годов полнотекстовый поиск стал ключевой технологией для веб-серверов и поисковых систем. Google, например, устроил революцию в поисковых технологиях, используя PageRank и другие методы для улучшения результатов поиска.

С появлением больших данных и сложных систем хранения информации необходимость во встроенных возможностях полнотекстового поиска в системах управления базами данных (СУБД) выросла. В начале 2000-х годов был разработан модуль TSearch, который впервые представил продвинутые возможности полнотекстового поиска в PostgreSQL. Он позволил индексировать текст и выполнять поиск по нему более эффективно.

TSearch2 стал логическим продолжением первой версии модуля. Именно здесь появились tsvector и tsquery как отдельные типы данных. Tsvector — это нормализованное представление текста, которое содержит уникальные лексемы (основные формы слов) и позиции их вхождения. Tsquery используется для описания условий поиска по tsvector.

Одной из основных задач, которые решают tsvector и tsquery, является оптимизация полнотекстового поиска. Tsvector позволяет преобразовать текстовые данные в форму, удобную для поиска и индексации. Это снижает объем выполняемых операций и ускоряет процесс извлечения информации из больших объемов текстов.

Кстати, авторы функционала наши соотечественики.

Что такое SearchVector и триграммы

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

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

Триграммы — техника, разбивающая текст на группы из трех символов. Например, слово «поиск» можно разбить на триграммы: «пои», «оис», «иск». Этот метод позволяет находить совпадения даже в случае опечаток или незначительных расхождений. В Django триграммы используются для реализации нечёткого поиска, что особенно полезно, когда пользователи могут совершать ошибки при вводе запросов.

Комбинирование SearchVector и триграмм позволяет создавать хорошие поисковые решения в своих проектах — SearchVector обеспечивает точный и быстрый поиск по хорошо структурированному тексту, в то время как триграммы добавляют гибкость, позволяя учитывать человеческий фактор, как опечатки или вариации в написании. Если использовать оба подхода, можно сделать приложения более удобными и интуитивно понятными для пользователей.

Итак, использование SearchVector и триграмм в Django помогает создавать более точные и гибкие поисковые алгоритмы. Это значительно увеличивает эффективность работы с данными, улучшая пользовательский опыт.

Как оно работает в postgresql?

В PostgreSQL полнотекстовый поиск реализован через специальные типы данных и операторы, такие как tsvector и tsquery.

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

Создание tsvector происходит с помощью функции to_tsvector(). Функция to_tsvector() автоматически анализирует и преобразовывает текстовые данные в tsvector. Она отбрасывает стоп-слова, которые не имеют большого смысла для поиска (например, the, is), и приводит остальные к корневой форме.

Пример:

SELECT to_tsvector('english', 'The quick brown fox jumps over the lazy dog.');

В результате вы получите tsvector, содержащий что-то вроде: ‘brown’:3 ‘dog’:9 ‘fox’:4 ‘jump’:5 ‘lazi’:8 ‘quick’:2.

Каждое слово преобразуется в форму «лексемы»: «jumps» → «jump», «lazy» → «lazi»; сохраняется информация о позиции слов в тексте.

tsquery — это тип данных, который представляет поисковый запрос. Он работает с теми же лексемами и поддерживает логические операторы, чтобы можно было составлять сложные поисковые выражения.

Для создания tsquery используется функция to_tsquery(). Примеры возможных операторов:

AND (&): Оба слова должны присутствовать. Пример: quick & fox.

OR (|): Любое из слов может присутствовать. Пример: quick | fox.

NOT (!) или -: Исключить слово. Пример: quick & !lazy.

Пример:

SELECT to_tsquery('english', 'quick & fox');

Этот запрос будет искать все документы, где встречаются оба слова «quick» и «fox».

Для выполнения полнотекстового поиска нужно сопоставить tsvector и tsquery с помощью оператора @@.

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

SELECT * FROM documents WHERE to_tsvector('english', content) @@ to_tsquery('english', 'quick & fox');

Этот запрос вернет все строки из таблицы documents, где в столбце content встречаются слова «quick» и «fox».

А что с индексами?

Поскольку полнотекстовый поиск может быть ресурсозатратным, часто используется индекс GIN (Generalized Inverted Index) для ускорения поиска по tsvector. 

Что такое GIN-индекс?

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

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

Создание GIN-индекса в PostgreSQL для полнотекстового поиска можно выполнить с помощью SQL-запроса:

CREATE INDEX idx_documents_content ON documents USING GIN(to_tsvector(‘english’, content));

Это позволяет ускорить выполнение запросов, сопоставляющих tsvector и tsquery.

В Django интеграция с PostgreSQL и его функциями полнотекстового поиска может быть реализована с помощью специальных методов и подходов. Одна из сторонних библиотек, облегчающих работу с полнотекстовым поиском в Django, — это django.contrib.postgres.

Шаги для создания GIN-индекса в Django:

Подготовка модели:

Модель должна иметь  текстовое поле, по которому будет выполняться поиск. Например:

from django.db import models   class Document(models.Model):     title = models.TextField()     content = models.TextField()

Настройка индексации:

Импортируем класс GinIndex из модуля django.contrib.postgres.indexes, он позволит создать индекс GIN для текстового поля.

И вот тут, если следовать документации в Django, нас ожидает ошибка, т.к. обычный GIN-индекс не оптимально работает с Triggram-поиском. Лучше использовать gin_trgm_ops. Это позволит функциям (например, similarity), работать в разы быстрее, чем при обычном gin-индексе.

from django.contrib.postgres.indexes import GinIndex   class Document(models.Model):     title = models.TextField()     content = models.TextField()      class Meta:         indexes = [             GinIndex(fields=['content']),         ] 

Выполнение полнотекстового поиска:

Используйте Django ORM для выполнения полнотекстового поиска, применяя SearchVector и SearchQuery.

from django.contrib.postgres.search import SearchVector, SearchQuery from .models import Document   def search_documents(search_term):     vector = SearchVector('content')     query = SearchQuery(search_term)     results = Document.objects.annotate(search=vector).filter(search=query)     return results 

Как оно работает в django?

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

from django.contrib.postgres.search import SearchVector from django.db.models import F from .models import Article   # Создаем вектор поиска на основе поля 'title' и 'content' vector = SearchVector('title', 'content')  # Выполняем поиск по вектору articles = Article.objects.annotate(search=vector).filter(search='поиск') 

В этом примере мы создаем SearchVector, который включает поля title и content из модели Article. Затем мы используем его для фильтрации записей, содержащих искомый текст.

Чтобы использовать триграммы в Django, необходимо установить расширение trgm в PostgreSQL.

Сделать это можно двумя способами. Первый — через миграции:

from django.contrib.postgres.operations import TrigramExtension   class Migration(migrations.Migration):      operations = [         TrigramExtension(),     ] 

Второй — напрямую в БД:

CREATE EXTENSION pg_trgm;

В обоих случаях после добавления расширения нужно включить ‘django.contrib.postgres’ в INSTALLED_APPS.

После этого можно использовать модули Django для работы с ними. Приведу пример использования триграмм:

from django.contrib.postgres.search import TrigramSimilarity from .models import Article   # Рассчитываем сходство для каждой записи similar_articles = Article.objects.annotate(     similarity=TrigramSimilarity('title', 'искомый текст') ).filter(similarity__gt=0.3).order_by('-similarity') 

В SQL в этот момент происходит вот что:

SELECT *, similarity(title, 'искомый текст') AS similarity FROM Article WHERE similarity(title, 'искомый текст') > 0.3 ORDER BY similarity DESC;

Здесь TrigramSimilarity вычисляет степень схожести между строками. Мы фильтруем статьи, у которых схожесть больше 0.3 — это порог, который вы можете настроить в зависимости от нужд проекта.

Пример комбинированного использования может выглядеть так:

from django.contrib.postgres.search import SearchVector, TrigramSimilarity   articles = Article.objects.annotate(     search=SearchVector('title', 'content'),     similarity=TrigramSimilarity('title', 'искомый текст') ).filter(search='искомый текст').order_by('-similarity') 

Здесь наш SQL-запрос будет выглядеть так:

SELECT *, to_tsvector(title  ' '  content) AS search, similarity(title, 'искомый текст') AS similarity FROM Article WHERE to_tsvector(title  ' '  content) @@ plainto_tsquery('искомый текст') ORDER BY similarity DESC;

Предположим, у нас есть следующая таблица Article:

id

title

content

1

Как приготовить борщ

Рецепт: вода, свекла, капуста…

2

Готовим идеальный стейк

Жарим стейк до готовности…

3

Советы по уходу за садом

Полив, обрезка и удобрение растений

4

Приготовление супа

Ингредиенты для супа: вода, соль…

5

Борщ: классический рецепт

Борщ с говядиной и свеклой…

Поиск по триграммному сходству:

similar_articles = Article.objects.annotate(     similarity=TrigramSimilarity('title', 'борщ') ).filter(similarity__gt=0.3).order_by('-similarity')

Этот запрос находит статьи с заголовком, похожим на «борщ».

Результаты:

id

title

similarity

5

Борщ: классический рецепт

0.55

1

Как приготовить борщ

0.50

Запрос:

articles = Article.objects.annotate(     search=SearchVector('title', 'content'),     similarity=TrigramSimilarity('title', 'борщ') ).filter(search='борщ').order_by('-similarity')

Этот запрос выполняет полнотекстовый поиск в полях title и content и сортирует по триграммному сходству заголовка с «борщ».

Результаты:

id

title

similarity

search_matches

5

Борщ: классический рецепт

0.55

title, content

1

Как приготовить борщ

0.50

title, content

Таким образом, SearchVector быстро находит статьи, содержащие указанные слова, а триграммы помогают ранжировать найденные результаты в зависимости от их схожести с поисковым запросом.

Примеры

Как все это использовать без предварительного сохранения в базе, я уже разобрала базово выше. В двух словах — вот так:

from django.contrib.postgres.search import SearchVector from django.db.models import F from .models import Article   # Создаем вектор поиска на основе поля 'title' и 'content' vector = SearchVector('title', 'content')  # Выполняем поиск по вектору articles = Article.objects.annotate(search=vector).filter(search='поиск')

Но поиск будет работать быстрее, если какие-то данные мы все же предварительно подготовим. Пример:

Создаем модель, в которую будем записывать, например, все предложения с тегом h1 и h2:

from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.search import SearchVectorField, SearchVector from django.db import models from search_engine.queryset import SearchPageQuerySet   class SearchPage(models.Model):     """Страницы."""      objects = SearchPageQuerySet.as_manager()     link = models.URLField("Ссылка на страницу", max_length=200)     h1 = models.TextField("Предложения с тэгом <h1>", blank=True)     h2 = models.TextField("Предложения с тэгом <h2>", blank=True)     search_vector = SearchVectorField(null=True)     page_html = models.TextField("HTML страницы", blank=True)     update_date = models.DateTimeField("Обновлён", auto_now_add=True)      class Meta:         verbose_name = "Страница"         verbose_name_plural = "Страницы"         indexes = [             GinIndex(name='h1_trgm_gin_ops', fields=["h1"], opclasses=['gin_trgm_ops']),             GinIndex(name='h2_trgm_gin_ops',fields=["h2"], opclasses=['gin_trgm_ops']),             GinIndex(fields=["search_vector"]),         ]      def __str__(self):        return self.link 

Отдавать ее будем таким представлением, допустим нам нужно получать только ссылки на соответствующие страницы на сайте:

class SearchPageViewSet(GenericViewSet):     """Поиск."""      pagination_class = None      def get_queryset(self):         return SearchPage.objects.all()      def get_serializer_class(self):         return SearchPageSerializer      @action(detail=False, methods=["GET"])     def search_links(self, request, args, *kwargs):         """Эндпоинт для получения ссылок на разделы сайта."""          queryset = self.get_queryset()         if search_request := request.query_params.get("search_request"):             search_request = search_request.strip()             queryset = (                 queryset.annotate_headline(search_request)                 .annotate_similarity(search_request)                 .filter(search_vector=search_request)                 .order_by("-best_similarity")             )         serializer = self.get_serializer(queryset[:RESPONSE_LIMIT], many=True)         return Response(serializer.data, status=status.HTTP_200_OK) 

А вот так должен выглядеть наш SearchPageQuerySet:

from django.contrib.postgres.search import SearchHeadline, TrigramSimilarity from django.db.models import QuerySet, Case, When, Q, CharField, F, FloatField from django.db.models.functions import Greatest   HEADLINE_OPEN_TAG: str = "<b>"  # тэг для обозначения слова из поиска в предложении HEADLINE_CLOSE_TAG: str = "</b>"  # тэг для обозначения слова из поиска в предложении   class SearchPageQuerySet(QuerySet):     """Менеджер поиска."""      def aliash1_headline(self, search_request: str) -> "SearchPageQuerySet":         return self.alias(             h1_headline=SearchHeadline(                 "h1",                 search_request,                 start_sel=HEADLINE_OPEN_TAG,                 stop_sel=HEADLINE_CLOSE_TAG,             ),         )      def aliash2_headline(self, search_request: str) -> "SearchPageQuerySet":         return self.alias(             h2_headline=SearchHeadline(                 "h2",                 search_request,                 start_sel=HEADLINE_OPEN_TAG,                 stop_sel=HEADLINE_CLOSE_TAG,             ),         )      def annotate_headline(self, search_request: str) -> "SearchPageQuerySet":         return (             self._alias_h1_headline(search_request)             ._alias_h2_headline(search_request)             .annotate(                 headline=Case(                     When(Q(h1_headline__contains=HEADLINE_OPEN_TAG), then=F("h1_headline")),                     When(Q(h2_headline__contains=HEADLINE_OPEN_TAG), then=F("h2_headline"))                     default=None,                     output_field=CharField(),                 ),             )         )      def annotate_similarity(self, search_request: str) -> "SearchPageQuerySet":         return self.annotate(             similarity_h1=Case(                 When(                     ~Q(h1=""),                     then=TrigramSimilarity(                         F("h1"),                         str(search_request),                     )                 ),                 default=0,                 output_field=FloatField()             ),             similarity_h2=Case(                 When(                     ~Q(h2=""),                     then=TrigramSimilarity(                         F("h2"),                         str(search_request),                     )                 ),                 default=0,                 output_field=FloatField()             )             best_similarity=Greatest(F("similarity_h1"), F("similarity_h2"))         ) 

Итоговый SQL-запрос у представления будет такой:

SELECT "search_engine_searchpage"."id",       "search_engine_searchpage"."link",       "search_engine_searchpage"."h1",       "search_engine_searchpage"."h2",       CASE           WHEN ts_headline("search_engine_searchpage"."h1", plainto_tsquery('sell'),                            'StartSel=''<b>'', StopSel=''</b>''')::text LIKE '%<b>%' THEN ts_headline(                   "search_engine_searchpage"."h1", plainto_tsquery('sell'), 'StartSel=''<b>'', StopSel=''</b>''')           WHEN ts_headline("search_engine_searchpage"."h2", plainto_tsquery('sell'),                            'StartSel=''<b>'', StopSel=''</b>''')::text LIKE '%<b>%' THEN ts_headline(                   "search_engine_searchpage"."h2", plainto_tsquery('sell'), 'StartSel=''<b>'', StopSel=''</b>''')           ELSE NULL           END                                  AS "headline",       CASE           WHEN NOT ("search_engine_searchpage"."h1" = '') THEN SIMILARITY("search_engine_searchpage"."h1", 'sell')           ELSE 0           END                                  AS "similarity_h1",       CASE           WHEN NOT ("search_engine_searchpage"."h2" = '') THEN SIMILARITY("search_engine_searchpage"."h2", 'sell')           ELSE 0           END                                  AS "similarity_h2",       GREATEST(CASE                    WHEN NOT ("search_engine_searchpage"."h1" = '')                        THEN SIMILARITY("search_engine_searchpage"."h1", 'sell')                    ELSE 0 END, CASE                                    WHEN NOT ("search_engine_searchpage"."h2" = '')                                        THEN SIMILARITY("search_engine_searchpage"."h2", 'sell')                                    ELSE 0 END) AS "best_similarity" FROM "search_engine_searchpage" WHERE "search_engine_searchpage"."search_vector" @@ plainto_tsquery('sell') ORDER BY "best_similarity" DESC LIMIT 5

А забирать данные при этом он будет примерно из такой таблицы:

id

link

h1

h2

headline

similarity_h1

similarity_h2

best_similarity

1

example.com/a

Пример данные

Дополнительная информация

<b>Пример</b> данные

0.8

0.0

0.8

2

example.com/b

Еще пример

Пример данных

NULL

0.6

0.9

0.9

Такой столбец headline будет содержать разметку <b> вокруг слов, соответствующих поисковому запросу. Столбцы similarity_h1 и similarity_h2 показывают степень схожести строк с запросом, а best_similarity выбирает наибольшее значение из них для сортировки. 

Дальше начинается самое интересное — чтобы что-то отдать, нам нужно собрать данные по сайту. 

Для этого нам понадобится «паук» — сервис, который будет ходить, например, по сайтмэпу, собирать страницы, искать в них заголовки h1 и h2 (и какие угодно еще теги). Подробнее про спайдеры, какие еще они бывают, и как их использовать, можно посмотреть здесь.

Для примера возьмем за основу SitemapSpider:

class SiteSpider(SitemapSpider):     """Паук для сбора данных со всех страниц сайта через sitemap."""      name: str = "site_spider"     sitemap_urls: list[str] = [settings.SITE_MAP]     http_user = (         getenv("BASIC_AUTH").split(":")[0]         if getenv("BASIC_AUTH") and getenv("HTPASSWD_NODE") != "off"         else None     )      http_pass = (         getenv("BASIC_AUTH").split(":")[1]         if getenv("BASIC_AUTH") and getenv("HTPASSWD_NODE") != "off"         else None     )      parse_tags: list[str] = PARSE_TAGS     replace_item_with_space: list[str] = REPLACE_ITEM_WITH_SPACE     remove_items: list[str] = REMOVE_ITEMS     specific_chars: str = REMOVE_SPECIFIC_CHARS     search_model: Type[Model] = SearchPage  # модель, куда сохраняем спарсенные данные      def parse(self, response: Response, **kwargs: Any) -> None:         """Основная функция, которая вызывается при запуске паука."""          data = self._collect_data_from_site(response)         self._create_update_model(data)      def collectdata_from_site(self, response: Response) -> dict[str, dict[str, str]]:        """Парсинг данных по страницам."""          url = response.request.url         data: dict[str, dict[str, str]] = {}         sentence_dict: dict[str, str] = {}         for tag in self.parse_tags:             sentence_dict[tag] = self._get_tag_text_from_response(tag, response)         data[f"{url}"] = sentence_dict         return data      def gettag_text_from_response(self, tag: str, response: Response) -> str:         """Получения списка текстов тэга."""          sentences: list = []         for sentence in response.css(f"{tag}::text").getall():             sentence = sentence.strip()             for item in self.replace_item_with_space:                 sentence = sentence.replace(item, " ")             for item in self.remove_items:                 sentence = sentence.replace(item, "")             if sentence and sentence not in sentences:                 sentences.append(sentence)         return ";".join(sentences)      def createupdate_model(self, data: dict[str, dict[str, str]]) -> None:         """Обновление/создание моделей с данными для поиска."""          for link, tags in data.items():             try:                 search_model = self.search_model.objects.get(link=link)                 if tags:                     search_model.h1 = tags.get("h1")                     search_model.h2 = tags.get("h2")                     search_model.save(force_update=True)                 else:                     search_model.delete()  # удаление объекта, если нет ключевых слов и предложений для поиска             except self.search_model.DoesNotExist:                 if tags:                     search_model = self.search_model(                         link=link,                         h1=tags.get("h1"),                         h2=tags.get("h2"),                     )                     search_model.save()

Нужно понимать, что при использовании в виде нам будет необходимо пересчитывать поля SearchVectorField. Для этого можно написать сигнал, чтобы он запускал сбор данных при сохранении интересующих нас моделей или задачу, которая будет заниматься этим по расписанию — все, данные готовы, поиск работает, вы молодец!

И еще момент: мы используем подход предварительного расчет search_vector. Это реализовано с помощью django signals. То есть при сохранении страницы мы всегда пересчитываем итоговый поисковый вектор и сохраняем его. Это позволяет оптимизировать скорость выполнения SQL, поскольку не нужно переводить строки в ts_vector в момент выполнения запроса.

@receiver(post_save, sender=SearchPage) def calculate_search_vector(instance: SearchPage, **kwargs: dict) -> None:    """Расчет поисковых векторов."""      search_vector = (             SearchVector("h1", weight="A")             + SearchVector("h2", weight="B")     )     SearchPage.objects.filter(id=instance.id).update(search_vector=search_vector)

А что под нагрузкой, м?

Сейчас проверим. Для генерации нагрузки будем использовать Grafana K6

Методология тестирования

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

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

Настройки сервера типичные: 16 ядер и 16 гигабайт оперативной памяти. Веб-сервер запустим на gunicorn со следующими настройками: gunicorn config.wsgi:application -w 16 —keep-alive 120 -b 0.0.0.0:8000 —max-requests 10000 —max-requests-jitter 1000

Вот наш базовый сценарий:

// Начало сценария const word_list = ['young', 'yet', 'yes', 'year', 'yeah', 'yard', 'wrong', 'writer', 'write', 'would']  // Проходимся по списку слов и запрашиваем похожие слова for (const word of word_list) {    let cut_word = word.slice(0, 3);    console.log(word, cut_word);    let response = requestGetAPI(/api/search_page/search_words/?format=json&search_request=${cut_word}, {tags: {type: 'search_words'}})    sleep(1)    let response_word_list = response.json()    console.log(response_word_list)     // Итерируемся по списку полученных слов    for (const response_word of response_word_list) {        // Получаем ссылки        requestGetAPI(/api/search_page/search_links/?format=json&search_request=${response_word}, {tags: {type: 'search_link'}})        sleep(1)    } }

Для начала запустим Smoke-тестирование, чтобы понять, что все наши API работают и отдают нужные данные. Параметры следующие:

// SMOKE TEST  smoke_test_api: {     // Функция генерации нагрузки, их там много, почитайте документацию K6    executor: 'constant-vus',     // Количество  "виртуальных пользователей"    vus: 5,     // Время выполнения теста    duration: '20s',     // Какую функцию мы запускаем    exec: 'scenario_api', }

Результаты:

При тестировании будем опираться на метрики http_req_duration (за сколько секунд выполнился запрос), а именно: 95 персентиль (он равен 191 мс), а также на http_reqs (количество запросов в секунд; здесь он равен 4.4 запроса в секунду). Насколько хороший результат выйдет без кеширования, решать вам, но для нас с учетом синхронной Django это уже отлично.

Итак, давайте проведем BREAKPOINT TEST, в котором постепенно повысим RPS и будем ждать момента, когда API начнет деградировать.

Конфигурация теста:

// BREAKPOINT TEST  breakpoint_test_api: {    executor: 'ramping-arrival-rate',    preAllocatedVUs: 100,    maxVUs: 10000,    stages: [        {duration: '1m', target: 10},        {duration: '5m', target: 1000},    ],    exec: 'scenario_api', }

По итогам мы получили такие показатели:

Почти 40% нагрузки ушло на бэкенд и 60% — на базу данных. Конечно, в текущем кейсе синхронная Django на gunicorn будет на порядки проигрывать по производительности асинхронным python фрейморкам. Но тут главное — получить приблизительные цифры, от которых можно отталкиваться при дальнейших тестах.

По графикам в grafana видно, что при 50-60 RPS начали появляться timeout соединений.

В отчете самой Grafana k6 можно увидеть чуть больше информации. Среднее время ожидания (http_req_duration) под нагрузкой составило 38 секунд, что, конечно, ужасно, но тут сильно повлияли упавшие запросы. А количество запросов в секунду вышло 52, но тут стоит отнять 10-15%, ведь предельный RPS сильно смазывается упавшими запросами. 

В текущей конфигурации эти данные нам подходят.

Дальше в идеале нужно рассмотреть производительность самой БД — в отрыве от использования бекенда.

Но тут я стала проверять конфигурацию БД и поняла, что мы использовали стандартную конфигурацию и не выжали все возможные соки из БД в базовой связке. Поэтому давайте поставим нормальные настройки и проведем еще один нагрузочный тест 🙂

Конфигурация БД:

 db: image: postgres:17-alpine volumes:   - postgresdata17:/var/lib/postgresql/data restart: unless-stopped ports:   - "5432:5432" environment:   - POSTGRES_PASSWORD   - POSTGRES_PORT   - POSTGRES_NAME   - POSTGRES_USER   - POSTGRES_HOST_AUTH_METHOD=trust command:   - "postgres"   - "-c"   - "max_connections=150"   - "-c"   - "shared_buffers=4GB" # 25% от текущей оперативной памяти   - "-c"   - "effective_cache_size=4GB"   - "-c"   - "work_mem=64MB" # shared_buffers поделить на max_connections. Если получается меньше 32МБ, то оставить 32МБ   - "-c"   - "maintenance_work_mem=1024MB" # 10% от оперативной памяти   - "-c"   - "temp_file_limit=10GB"   - "-c"   - "idle_in_transaction_session_timeout=10s"   - "-c"   - "lock_timeout=1s"   - "-c"   - "statement_timeout=60s"   - "-c"   - "shared_preload_libraries=pg_stat_statements"   - "-c"   - "pg_stat_statements.max=10000"   - "-c"   - "pg_stat_statements.track=all"

Нагрузка распределилась почти так же, как и в прошлый раз.

RPS-показатели те же самые.

Здесь показатели примерно такие же, но http_reqs ниже, ведь как только тест начал выдавать ошибки, мы сразу его отключили. Это можно заметить по параметру checks — он выше, чем в прошлый раз. Так что этот тест более релевантный, чем прошлый.

Собственно, базовые настройки БД не оказали сильного влияния на работу сервиса в связке Django + БД. 

Заключение 

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

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

На этом у меня все, если есть что добавить или посоветовать — велком в комменты 🙂 

Источники:

https://www.postgresql.org/docs/current/textsearch.html

https://www.postgresql.org/docs/current/textsearch-tables.html

https://postgrespro.ru/docs/postgresql/17/pgtrgm 

https://docs.scrapy.org/en/latest/topics/spider-middleware.html

https://habr.com/ru/companies/vdsina/articles/527534/


ссылка на оригинал статьи https://habr.com/ru/articles/891578/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *