Как стать автором
Обновить
112.01
ИдаПроджект
Proptech разработчик №1

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

Уровень сложностиСредний
Время на прочтение28 мин
Количество просмотров4.8K

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

Теги:
Хабы:
Всего голосов 29: ↑29 и ↓0+31
Комментарии3

Публикации

Информация

Сайт
idaproject.com
Дата регистрации
Дата основания
2013
Численность
201–500 человек
Местоположение
Россия
Представитель
Egor