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

У вас растёт таблица статей или товаров, и filter(title__icontains=query) начинает искать всё медленнее, при этом не понимает ни морфологию, ни опечатки, ни релевантность. В Django есть модуль django.contrib.postgres.search, который отдаёт поиск на сторону PostgreSQL: он умеет ранжировать результаты, учитывать словоформы и работать по индексу. Ниже — чек-лист того, что нужно проверить и настроить, чтобы поиск был не только корректным, но и быстрым. Статья про приёмы работы, а не про внутренности tsvector — устройство индекса упомянем только там, где это влияет на решение.
Версии, для которых актуально: Django 4.2–5.x, PostgreSQL 13–16. Всё, что описано, работает только на PostgreSQL — на SQLite и MySQL этот модуль недоступен.
Что перепутать проще всего: три уровня поиска
В django.contrib.postgres.search есть три инструмента, и выбор между ними — первое, что нужно сделать осознанно, а не по первому примеру из документации.
- SearchVector + SearchQuery — полноценный полнотекстовый поиск с лемматизацией и ранжированием. Основной рабочий инструмент.
- TrigramSimilarity (из
django.contrib.postgres.search) — поиск по похожести строк, ловит опечатки и частичные совпадения. Требует расширенияpg_trgm. - Простой icontains — для маленьких таблиц и точных подстрок. Никакого стыда его оставить, если строк несколько тысяч.
Полнотекстовый поиск ищет по словам и их формам, а не по подстроке. Запрос «работают» найдёт документ со словом «работать», но не найдёт «работа» как часть «работать» — если такой куски строки вам нужны, это задача для триграмм, а не для tsvector.
Сравнение подходов
| Подход | Ловит опечатки | Понимает словоформы | Ранжирует | Нужен индекс на |
|---|---|---|---|---|
| icontains | Нет | Нет | Нет | — |
| SearchVector | Нет | Да (по словарю) | Да (SearchRank) | GIN на tsvector |
| TrigramSimilarity | Да | Нет | Да (по similarity) | GIN с gin_trgm_ops |
Чек-лист настройки
1. Подключить приложение и расширения
Добавьте django.contrib.postgres в INSTALLED_APPS. Для триграммного поиска и функций similarity нужно расширение pg_trgm, оно ставится через миграцию с операцией TrigramExtension.
# apps/search/migrations/0002_trgm.py
from django.contrib.postgres.operations import TrigramExtension
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("search", "0001_initial")]
operations = [
TrigramExtension(),
]
Проверка: после manage.py migrate выполните в psql SELECT * FROM pg_extension WHERE extname = 'pg_trgm'; — должна вернуться одна строка.
2. Определиться с языком (конфигурацией поиска)
По умолчанию PostgreSQL использует конфигурацию simple, которая не лемматизирует. Для русского текста передавайте config='russian' в SearchVector и SearchQuery — иначе «города» и «город» будут считаться разными словами.
from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank
vector = SearchVector("title", "body", config="russian")
query = SearchQuery("городской транспорт", config="russian")
results = (
Article.objects
.annotate(rank=SearchRank(vector, query))
.filter(rank__gt=0)
.order_by("-rank")
)
Важно: конфигурация в векторе и в запросе должна совпадать. Разные конфигурации дают несовпадающие лексемы, и поиск молча вернёт пусто.
3. Не считать вектор на каждый запрос — хранить его в поле
Код выше пересчитывает tsvector для каждой строки при каждом запросе. На нескольких сотнях записей это незаметно, на десятках тысяч — полный скан таблицы. Решение — материализованное поле SearchVectorField, которое обновляется при сохранении, а не при поиске.
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=255)
body = models.TextField()
search_vector = SearchVectorField(null=True)
class Meta:
indexes = [GinIndex(fields=["search_vector"])]
Проверка: после миграции в выводе \d+ your_app_article в psql должен появиться GIN-индекс на search_vector.
4. Обновлять материализованное поле
Django не заполняет SearchVectorField сам. Есть два рабочих способа.
- Через сигнал или переопределённый save — при сохранении объекта пересчитываете вектор и записываете. Просто, но save на большом импорте станет медленным.
- Через триггер в БД — PostgreSQL сам обновляет поле при INSERT/UPDATE. Быстрее и надёжнее для активной записи, но триггер придётся завести через
migrations.RunSQLи поддерживать вручную.
Простой вариант обновления пачкой (например, из management-команды после импорта):
from django.contrib.postgres.search import SearchVector
Article.objects.update(
search_vector=SearchVector("title", "body", config="russian")
)
5. Взвесить поля по важности
Совпадение в заголовке обычно важнее совпадения в теле. SearchVector принимает параметр weight со значениями от 'A' (высший) до 'D'. Складывайте векторы с разным весом.
vector = (
SearchVector("title", weight="A", config="russian")
+ SearchVector("body", weight="B", config="russian")
)
Проверка: при поиске статья, где слово в заголовке, должна оказаться выше статьи, где то же слово только в теле.
6. Добавить устойчивость к опечаткам через триграммы
Полнотекстовый поиск не прощает опечаток. Если пользователь напишет «трнспорт», SearchQuery не найдёт ничего. Для таких случаев подключают TrigramSimilarity как запасной или дополнительный сигнал.
from django.contrib.postgres.search import TrigramSimilarity
results = (
Article.objects
.annotate(sim=TrigramSimilarity("title", "трнспорт"))
.filter(sim__gt=0.3)
.order_by("-sim")
)
Порог 0.3 — стартовое значение, его подбирают по своим данным. Слишком низкий даёт мусор, слишком высокий не ловит опечатки.
Когда что выбирать
- Таблица до нескольких тысяч строк, точные подстроки — оставьте
icontains. Индекс и материализованное поле не окупятся. - Контент на русском, важна релевантность —
SearchVectorField+ GIN +config='russian'+ веса полей. - Пользователи часто ошибаются в написании — добавьте триграммы поверх основного поиска или комбинируйте оба сигнала в одном запросе.
Что ломается на практике
- Разные config в векторе и запросе — поиск возвращает пусто без ошибки. Проверяйте, что оба используют одну конфигурацию.
- Забыли обновить SearchVectorField — новые записи не находятся, потому что поле осталось null. При импорте всегда прогоняйте пересчёт.
- Вектор считается на лету на большой таблице — запрос делает seq scan, поиск тормозит секундами. Смотрите
EXPLAIN ANALYZE: если там Seq Scan вместо Bitmap Index Scan, индекс не используется. - GIN-индекс не подхватывается — он работает только по материализованному полю, а не по вычисляемому в annotate векторе. Это частая причина, почему индекс «есть, но не помогает».
Что сделать дальше
Снимите EXPLAIN ANALYZE на реальном объёме данных до и после того, как переведёте поиск на материализованное поле с GIN-индексом, — это единственный способ убедиться, что индекс действительно используется. Дальше стоит настроить обновление вектора через триггер БД, если у вас частая запись, и подобрать порог similarity для триггеров по логам реальных запросов. Русскоязычный стемминг из коробки простой; если нужен разбор сложной морфологии или синонимы — это уже отдельная тема с кастомными словарями PostgreSQL, здесь мы её не разворачиваем.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Full text search — Django documentation, pg_trgm — PostgreSQL documentation
Частые вопросы
Работает ли это на SQLite или MySQL?
У меня Django 4.2, а не 5.x — что-то отличается?
Поиск ничего не находит, хотя данные точно есть — в чём дело?
Нужен ли отдельный движок вроде Elasticsearch?
Как подобрать порог similarity для триграмм?
Индекс создан, но поиск всё равно медленный — почему?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.