Привет, меня зовут Таня и я 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