Нечеткий поиск
Задача на реализацию удобного поиска по названиям рецептов без отдельного поискового движка, но с устойчивостью к опечаткам и предметным синонимам.
Задача
Сделать поиск по рецептам, который:
- находит точные и частичные совпадения;
- не ломается из-за опечаток;
- понимает близкие по смыслу кулинарные термины;
- отдает наиболее релевантные результаты первыми.
Контекст
Поиск нужен был в нескольких местах: в общем каталоге рецептов и в списке сохраненных рецептов пользователя.
Простой поиск через ILIKE решает только базовый сценарий подстрочного совпадения, но быстро начинает проигрывать по качеству:
- пользователь может ошибиться в написании;
- одно и то же блюдо могут искать разными словами;
- результаты без дополнительного ранжирования часто выглядят "технически найденными", но неудобными с точки зрения пользователя.
При этом для такой задачи не хотелось поднимать отдельный поисковый движок: это дало бы лишнюю инфраструктурную сложность для сравнительно локальной функции.
Рассмотренные варианты
1. Оставить только ILIKE
Плюсы:
- реализация очень простая;
- не требует дополнительной настройки
PostgreSQL.
Минусы:
- плохо переносит опечатки;
- не учитывает близкие предметные термины;
- не дает качественного ранжирования.
2. Подключить отдельный поисковый движок
Например, вынести поиск в специализированный сервис.
Плюсы:
- высокая гибкость в развитии поиска;
- можно строить сложные правила ранжирования и анализа текста.
Минусы:
- появляется дополнительная инфраструктура;
- растет стоимость сопровождения;
- для текущего масштаба задачи решение было бы избыточным.
3. Построить поиск на PostgreSQL pg_trgm и собственном ранжировании
Плюсы:
- не требует отдельного сервиса;
- хорошо работает с опечатками;
- позволяет комбинировать нечеткое совпадение с прикладными правилами домена.
Минусы:
- нужно вручную подбирать пороги и веса;
- качество выдачи зависит от аккуратно настроенного ранжирования.
Выбранное решение
В итоге поиск был построен на PostgreSQL с использованием расширения pg_trgm, GIN-индекса по названию рецепта и отдельного компонента RecipeNameSearch, который инкапсулирует правила фильтрации и сортировки.
Как работало решение
1. Нечеткое совпадение через pg_trgm
В базе было подключено расширение pg_trgm, а для поля recipes.name создан GIN-индекс с gin_trgm_ops.
Это позволило использовать функции similarity() и word_similarity() для поиска по строкам, где пользователь:
- допустил опечатку;
- ввел неполное название;
- ищет близкую по написанию форму.
2. Комбинация нескольких стратегий поиска
Поиск строился не на одном условии, а на комбинации нескольких сигналов:
- совпадение по
ILIKEкак подстроке; - совпадение по словарю предметных терминов;
- нечеткое совпадение по
similarity(...).
За счет этого запрос не был "чисто триграммным". Сначала учитывались очевидные пользовательские ожидания, а затем уже подключалось нечеткое сопоставление.
3. Предметные синонимы и близкие термины
Отдельно был добавлен небольшой прикладной словарь терминов, которые в реальном поиске логично считать близкими:
оладьиипанкейки;болтуньяискрэмбл;сырникиитворог;картошка,картофельипюре.
Это важный практический момент: в пользовательском поиске качество часто определяется не только алгоритмом сравнения строк, но и тем, насколько система понимает язык конкретного домена.
4. Явное ранжирование результатов
Чтобы наверху выдачи были не просто "похожие" строки, а действительно ожидаемые результаты, была добавлена собственная схема весов:
- точное совпадение названия;
- совпадение по префиксу запроса;
- совпадение по вхождению запроса в строку;
- совпадение по словарным терминам;
- дополнительная сортировка по
similarity()иword_similarity().
Итоговый поиск не просто отбирал кандидатов, а еще и сортировал их в понятном пользовательском порядке.
5. Повторное использование в нескольких сценариях
Логика поиска была вынесена в отдельный инфраструктурный компонент и использовалась как для общего списка рецептов, так и для списка сохраненных рецептов пользователя.
Это позволило:
- не дублировать SQL-логику;
- держать одинаковое поведение поиска в разных API-эндпоинтах;
- централизованно менять правила ранжирования.
Результат
В проекте появился прикладной нечеткий поиск, который:
- устойчив к части опечаток;
- понимает ряд доменных терминов и близких названий;
- не требует внешнего поискового сервиса;
- дает заметно более адекватную выдачу, чем простой поиск по подстроке.