Полнотекстовый поиск в Django: как сделать правильно
Поиск на icontains ложится под нагрузкой и не находит «телефоны» по запросу «телефон». Разбираем, как перейти на полнотекстовый поиск PostgreSQL средствами Django и что за это платить.

Классический сценарий: в проекте на Django поиск сделан через filter(title__icontains=query), каталог вырос до 200 тысяч записей, и страница поиска стала отвечать за две-три секунды. При этом запрос «телефоны» не находит товар «телефон», а «iphone» не находит «iPhone 15». Это не баг реализации — это предел, на который icontains выходит на любом сколько-нибудь большом объёме. Разберём, как заменить его на полнотекстовый поиск PostgreSQL через модуль django.contrib.postgres.search, и в каких точках выбор придётся делать сознательно.
Статья написана для Django 5.x и PostgreSQL 14+. Инвалидация индексов при массовых обновлениях и интеграция с внешними движками вроде Elasticsearch — отдельные темы, здесь только штатные средства ORM поверх PostgreSQL.
Почему icontains — тупик
filter(field__icontains=query) генерирует SQL с ILIKE '%query%'. Проблем сразу несколько.
- Нет индекса. B-tree индекс не помогает при поиске подстроки с ведущим
%, база делает полный проход по таблице. Отсюда рост времени с размером данных. - Нет морфологии. «Телефоны» и «телефон» — разные строки, точное вхождение не совпадает. Пользователь ждёт совпадения по слову, а не по подстроке.
- Нет ранжирования. Все совпадения равнозначны, самый релевантный результат не поднимается наверх.
Полнотекстовый поиск решает все три: разбивает текст на нормализованные лексемы, использует GIN-индекс и умеет считать релевантность.
Минимальный рабочий поиск: SearchVector
Начнём с самого простого — поиска на лету, без хранения индекса. Подходит для небольших таблиц или прототипа.
# Django 5.x, PostgreSQL с расширением по умолчанию
from django.contrib.postgres.search import SearchVector, SearchQuery
query = SearchQuery("телефон", config="russian")
vector = SearchVector("title", "description", config="russian")
products = Product.objects.annotate(
search=vector,
).filter(search=query)Параметр config="russian" включает русский словарь стемминга: «телефоны», «телефону», «телефоном» приводятся к одной лексеме. Без него по умолчанию берётся simple — без морфологии, что для русского текста почти всегда не то, что нужно.
Проблема этого варианта: SearchVector вычисляется для каждой строки при каждом запросе, индекс не задействован. На 200 тысячах записей это те же секунды, что и у icontains. Для продакшена вектор нужно хранить.
Хранимый вектор + GIN-индекс
Правильный путь — держать tsvector в отдельном поле модели и индексировать его. Django даёт для этого SearchVectorField.
from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex
from django.db import models
class Product(models.Model):
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
search_vector = SearchVectorField(null=True)
class Meta:
indexes = [
GinIndex(fields=["search_vector"]),
]После makemigrations и migrate поле и GIN-индекс появятся в базе. Теперь вектор нужно заполнить и поддерживать в актуальном состоянии.
Обновление вектора
Есть два подхода: обновлять через Django или через триггер в базе.
Через Django — явное обновление при сохранении или пакетно:
from django.contrib.postgres.search import SearchVector
# Пакетное обновление всех записей одним UPDATE
Product.objects.update(
search_vector=SearchVector(
"title", weight="A", config="russian"
) + SearchVector(
"description", weight="B", config="russian"
)
)Веса A, B, C, D задают приоритет полей при ранжировании: совпадение в заголовке (вес A) поднимется выше совпадения в описании (вес B). Это то, чего icontains дать не может в принципе.
Полнотекстовый поиск — это не «быстрый icontains», а другая модель совпадения. Пользователь ищет по словам и их формам, а не по подстрокам. Если вы просто хотите «содержит эту последовательность символов» — вам нужен не поиск, а фильтр, и это разные инструменты.
Триггер вместо ручного обновления
Ручное обновление легко забыть — например, при импорте данных в обход save(). Надёжнее держать логику в базе через триггер на tsvector_update_trigger. Django позволяет добавить его через RunSQL в миграции. Точный синтаксис функции tsvector_update_trigger и её ограничения (она не умеет весов и произвольного config для каждого столбца) смотрите в разделе Text Search документации PostgreSQL — для взвешенных векторов пишут свою функцию на PL/pgSQL. Не копируйте пример триггера вслепую: проверьте, что имя config и список столбцов совпадают с вашими.
Поиск с ранжированием
Собираем финальный запрос: фильтр по вектору плюс сортировка по релевантности.
from django.contrib.postgres.search import SearchQuery, SearchRank
from django.db.models import F
query = SearchQuery("беспроводные наушники", config="russian", search_type="websearch")
products = (
Product.objects
.annotate(rank=SearchRank(F("search_vector"), query))
.filter(search_vector=query)
.order_by("-rank")
)Параметр search_type="websearch" разбирает пользовательский ввод как поисковую строку: поддерживает кавычки для точных фраз и оператор OR, но не падает на «мусорном» вводе вроде лишних скобок. Для пользовательского поиска это безопаснее, чем plain или raw.
Сравнение подходов
| Подход | Индекс | Морфология | Когда брать |
|---|---|---|---|
icontains | Нет | Нет | Точное вхождение подстроки, маленькая таблица, поиск по коду/артикулу |
SearchVector на лету | Нет | Да | Прототип, до нескольких тысяч записей |
SearchVectorField + GIN | Да | Да | Продакшен, десятки тысяч записей и больше |
Trigram (pg_trgm) | Да (GIN) | Нет | Опечатки, поиск по частям слова, автодополнение |
Где это ломается
- Опечатки. Полнотекстовый поиск не прощает «телфон» вместо «телефон» — лексема не совпадёт. Для устойчивости к опечаткам нужен
TrigramSimilarityиз того же модуля с расширениемpg_trgm, часто в связке: сначала полнотекстовый, при пустой выдаче — триграммный fallback. - Стоп-слова. Словарь
russianвыкидывает предлоги и союзы. Запрос из одних стоп-слов даст пустой результат. - Смешанный язык. Один
configна поле обрабатывает текст по правилам одного языка. Каталог с русскими и английскими названиями требует либоconfig="simple"(без стемминга вообще), либо отдельных векторов. - Рассинхрон вектора. Если обновляете данные в обход
save()и без триггера, вектор устаревает молча — поиск перестаёт находить свежие записи, а ошибок нет. Это самый частый инцидент в проде.
Что померить и сделать дальше
После внедрения проверьте план запроса через EXPLAIN ANALYZE: в плане должен фигурировать Bitmap Index Scan по вашему GIN-индексу, а не Seq Scan. Если видите Seq Scan — вектор не хранится либо запрос идёт мимо индекса. Замерьте время ответа на реальном объёме данных до и после: типичный переход с icontains на хранимый вектор снимает секунды на больших таблицах, но точная цифра зависит от вашего железа, объёма и селективности запросов — меряйте у себя.
Следующие шаги: настроить fallback на триграммы для устойчивости к опечаткам, вынести обновление вектора в триггер, если данные меняются в обход ORM, и решить вопрос дебаунса запросов на фронтенде, чтобы не бить в базу на каждое нажатие клавиши.
Поиск источников: Реальные статьи и книги со ссылками, библиография по ГОСТ
Спросить за 15 ₽Источники: Django docs — Full text search (django.contrib.postgres.search), PostgreSQL Documentation — Full Text Search
Частые вопросы
А если у меня MySQL или SQLite, а не PostgreSQL?
Обязательно указывать config='russian'?
Поиск ничего не находит, хотя данные есть — в чём дело?
Как искать с учётом опечаток?
Нужно ли пересобирать вектор при каждом изменении записи?
Насколько GIN-индекс замедляет запись?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.