Привет, меня зовут Таня и я backend-разработчик в ИдаПроджект.
Сегодня хочу рассказать о полнотекстовом поиске — как это все работает в django, а как в postgres, и откуда вообще взялось.
Современные компании ежедневно сталкиваются с разной текстовой информацией. Эффективный поиск не только ускоряет доступ к нужным данным, но и повышает продуктивность, снижает затраты и открывает новые возможности для анализа и принятия решений.
Новичкам важно понять, как полнотекстовый поиск облегчает обработку данных и извлечение информации. Для тех, кто уже знаком с Django и PostgreSQL, статья станет экскурсом в полнотекстовый поиск, а заодно поможет интегрировать его в проекты.
Ну что, погнали! Разберем, как эта технология развивалась, и какие ее ключевые элементы (триграммы и tsvector) делают возможным быстрый и точный доступ к информации.
Оглавление
→ Откуда взялся полнотекстовый поиск?
→ Что такое SearchVector и триграммы
→ Как оно работает в postgresql?
Откуда взялся полнотекстовый поиск?
Начнем с определения.
Полнотекстовый поиск — технология поиска текстовой информации в электронных документах, где поиск выполняется по содержимому текста, а не только по метаданным или заголовкам. Такой подход стал необходимым с увеличением объемов данных и потребности извлекать нужную информацию быстро и эффективно. Давайте рассмотрим подробнее историческую основу и развитие этой технологии.
В 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 | Пример данные | Дополнительная информация | <b>Пример</b> данные | 0.8 | 0.0 | 0.8 | |
2 | Еще пример | Пример данных | 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
