Полнотекстовый поиск в Django: ошибки, которые бьют по скорости
SearchVector без индекса, поиск через icontains и молчаливая работа только с английским. Разбираем ошибки полнотекстового поиска в Django на PostgreSQL и как их чинить.

Полнотекстовый поиск в Django опирается на возможности PostgreSQL — модуль django.contrib.postgres.search появился ещё в Django 1.10 и живёт до 5.x почти без изменений в публичном API. Проблема не в API, а в том, как его применяют. Типичная картина: разработчик берёт SearchVector, вешает его на запрос, всё работает на тестовой базе из 200 строк, а на проде из 500 тысяч записей поиск по одному слову занимает 4 секунды. Ниже — приёмы, которые ломаются чаще всего, и что с ними делать.
Материал про PostgreSQL как бэкенд поиска. SQLite и MySQL полнотекстовый поиск Django-средствами не поддерживают на этом уровне — там либо сторонние движки вроде Elasticsearch, либо примитивный icontains. Инвалидация и обновление поискового индекса — отдельная тема, здесь только сам поиск и его типичные провалы.
Ошибка 1. Поиск через icontains вместо полнотекстового
Начинают почти все одинаково:
Article.objects.filter(body__icontains='платёж')На маленькой таблице это работает. На большой — превращается в ILIKE '%платёж%', а поиск с ведущим % не может использовать обычный B-tree индекс. PostgreSQL идёт последовательным сканом по всей таблице. И это не про «немного медленнее» — это линейный рост времени от размера таблицы.
Второй минус icontains: он ищет подстроку, а не слово. Запрос «платёж» не найдёт «платежи» и «платежей», потому что для базы это разные последовательности символов. Полнотекстовый поиск приводит слова к нормальной форме (лемме) и ищет по ней.
Если вы ловите себя на icontains по колонке с текстом, которую ищут пользователи, — это почти всегда сигнал, что нужен полнотекстовый поиск, а не более длинный фильтр.Ошибка 2. SearchVector без GIN-индекса
Правильный переход выглядит так:
from django.contrib.postgres.search import SearchVector, SearchQuery
vector = SearchVector('title', 'body')
query = SearchQuery('платёж', config='russian')
Article.objects.annotate(search=vector).filter(search=query)Работает верно, но каждый запрос заново вычисляет to_tsvector для всех строк таблицы. Индекс тут не используется вообще — вектор считается на лету. На больших таблицах это медленнее, чем icontains, потому что лемматизация дороже сравнения подстрок.
Решение — хранить вектор в отдельной колонке и построить по ней GIN-индекс. С Django 3.1 для этого есть SearchVectorField и GinIndex:
from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex
from django.db import models
class Article(models.Model):
title = models.CharField(max_length=200)
body = models.TextField()
search_vector = SearchVectorField(null=True)
class Meta:
indexes = [GinIndex(fields=['search_vector'])]После этого поиск фильтрует по индексированной колонке:
from django.contrib.postgres.search import SearchQuery
query = SearchQuery('платёж', config='russian')
Article.objects.filter(search_vector=query)Проверка, что индекс реально работает: возьмите SQL запроса через str(qs.query), выполните его с EXPLAIN ANALYZE в psql и убедитесь, что в плане есть Bitmap Index Scan по вашему GIN-индексу, а не Seq Scan.
Как заполнять search_vector
Колонку надо обновлять при сохранении. Три способа, у каждого своя цена:
- Через
updateв коде — вызываетеArticle.objects.filter(pk=...).update(search_vector=SearchVector('title', 'body', config='russian'))после сохранения. Просто, но легко забыть в одном из мест сохранения. - Триггер в базе — PostgreSQL сам пересчитывает вектор при INSERT/UPDATE. Надёжнее всего, потому что не зависит от того, через какой код прошла запись. Триггер создаётся миграцией с
migrations.RunSQL. - GeneratedField (Django 5.0+) — генерируемая колонка, которую база держит в актуальном состоянии сама. Синтаксис и ограничения смотрите в документации Django по версии, которую вы используете, — поведение с
tsvectorзависит от версии PostgreSQL.
Ошибка 3. Конфигурация по умолчанию вместо русской
Если не указать config, PostgreSQL берёт конфигурацию по умолчанию — обычно english. Для английского это работает: слова приводятся к основам, стоп-слова отбрасываются. Для русского текста английский словарь не делает ничего осмысленного: «платежами» и «платёж» останутся разными токенами, потому что английский стеммер не знает русской морфологии.
Важно: config должен совпадать в трёх местах — в SearchVector при построении индексируемой колонки, в SearchQuery при поиске и в самом индексе. Если вектор построен с russian, а запрос идёт с конфигурацией по умолчанию, совпадений не будет вообще либо будут неверные.
| Где указывается config | Что будет, если забыть |
|---|---|
| SearchVector (колонка) | Индекс построен по неправильным леммам, русские словоформы не схлопываются |
| SearchQuery (поиск) | Запрос лемматизируется другим словарём — расхождение с индексом |
| GinIndex / триггер | Индекс не совпадает с тем, что ищет запрос — Seq Scan |
Проверьте, что в вашей установке PostgreSQL вообще есть словарь russian: SELECT cfgname FROM pg_ts_config; в psql. В большинстве сборок он есть из коробки, но в урезанных Docker-образах может отсутствовать.
Ошибка 4. Один вес для всех полей
Совпадение в заголовке важнее совпадения в теле статьи, но по умолчанию они равнозначны. Django позволяет назначить полям веса от A (максимальный) до D:
from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank
vector = (
SearchVector('title', weight='A', config='russian')
+ SearchVector('body', weight='B', config='russian')
)
query = SearchQuery('платёж', config='russian')
Article.objects.annotate(
rank=SearchRank(vector, query)
).filter(rank__gte=0.1).order_by('-rank')Если веса вам нужны в выдаче, они должны быть учтены и в индексируемой колонке — то есть при заполнении search_vector тоже задавайте weight. Иначе SearchRank посчитает ранг по вектору, который не совпадает с индексом.
Ошибка 5. Пользователь вводит фразу, а SearchQuery ждёт термы
По умолчанию SearchQuery('красный шар') в режиме plain ищет документы, где есть и «красный», и «шар» (объединяет через И). Пользователь часто ждёт другого поведения. Django поддерживает несколько режимов через параметр search_type:
- plain (по умолчанию) — все слова обязательны, порядок не важен;
- phrase — точная фраза, слова подряд и в порядке;
- websearch (Django 2.2+, PostgreSQL 11+) — синтаксис как в поисковике: кавычки для фразы, минус для исключения, OR. Самый безопасный вариант для ввода от пользователя, потому что не падает на произвольном тексте;
- raw — сырой синтаксис
tsquery. Никогда не передавайте сюда необработанный пользовательский ввод: неверный синтаксис вызовет ошибку базы.
Для строки из поля поиска на сайте берите search_type='websearch':
query = SearchQuery(user_input, search_type='websearch', config='russian')Когда PostgreSQL-поиска уже мало
Встроенный поиск закрывает большинство задач: каталог, блог, база документов до нескольких миллионов строк. Он ломается на другом:
- нужны опечатки и нечёткое совпадение — тут помогает расширение
pg_trgmи триграммный поиск, это отдельный механизм, неtsvector; - десятки миллионов документов с высокой частотой обновления и требованием к скорости ответа под десятки миллисекунд — обычно уходят в Elasticsearch или OpenSearch;
- сложное ранжирование с учётом кликов, свежести и персонализации — за пределами возможностей
SearchRank.
Пока вы не упёрлись в одно из этого, выносить поиск во внешний движок — это лишняя инфраструктура, которую надо держать в актуальном состоянии с базой.
Что делать дальше
Порядок действий на живом проекте: добавьте SearchVectorField и GinIndex миграцией, заполните колонку через триггер или data-миграцию для существующих строк, переведите запросы на фильтр по индексированной колонке с config='russian' и search_type='websearch'. После деплоя снимите EXPLAIN ANALYZE на реальном объёме данных и убедитесь, что в плане Bitmap Index Scan, а время ответа не растёт с числом строк. Дальше смотрите на медленные запросы в pg_stat_statements — именно там всплывут забытые icontains.
Поиск источников: Реальные статьи и книги со ссылками, библиография по ГОСТ
Спросить за 15 ₽Источники: Full text search — Django documentation, PostgreSQL: Full Text Search
Частые вопросы
У меня Django 3.2, GeneratedField недоступен. Как обновлять search_vector?
Поиск ничего не находит, хотя слово точно есть в тексте. В чём дело?
Как убедиться, что GIN-индекс реально используется, а не идёт Seq Scan?
Что будет, если передать пользовательский ввод в SearchQuery с search_type='raw'?
В моём PostgreSQL нет словаря russian, поиск лемматизирует неправильно. Что делать?
Нужен ли внешний движок вроде Elasticsearch, если у меня 300 тысяч записей?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.