Возможно, вы имели в виду. Поиск с опечатками

К вам на сайт пришёл юзер и ввёл в поиск что-то вроде “рагняя поэзия пужкина”. Попробуйте сами на своём сайте. Не сработало? Тогда этот пост – для вас.

Есть несколько хороших способов решения этой задачи. Можно решать через SQL ILIKE, через soundex (sound index), через расстояние Левенштейна, через внешний индексатор на Elastic и многое другое. Сейчас я расскажу о том, что применил конкретно я, и почему такое решение я считаю интересным.

Данное решение позволяет:
– показывать подсказки “Возможно, вы имели в виду…”, то есть распознавать ошибочный ввод и позволять юзеру поправить фразу в один клик;
– непосредственно распознавать ошибки и сразу же искать по ним, не переспрашивая юзера;
– использовать вашу базу терминов, например специфических названий ваших продуктов, которую можно пополнять прямо на ходу без передеплоя проекта.

Решение дано на примере Rails+Postgresql.

1. Создаём модель Word с единственным атрибутом word:string (такие атрибуты как id, user_id, timestamps тоже желательны для дальнейшего удобства администрирования и пополнения базы, но на функциональность поиска не влияют). Внимание! Не делайте индекс на этот столбец сразу! Дальше объясню, почему.

2. Ищем в гугле и находим словари русского языка. Я скачал грамматический словарь Зализняка (90 тысяч слов) и словарь Русской литературы (160 тысяч слов), затем склеил их, отсортировал, выкинул дубликаты и выкинул слова меньше двух букв. В итоге у вас должен получиться файлик ‘db/russian_words.csv’, в котором написано по одному слову на каждой строке. Кавычки, точки с запятой, заголовки – не нужны. Просто файлик со словами.

3. Пишем миграцию вида:

CSV.foreach('db/russian_words.csv', :headers => false) do |row|
Word.create!(word: row.first.strip)
end

Не кидайте в меня помидорами! Можно залить файл и по-другому, например прямой командой COPY. Но у меня проект работает в контейнеризованном окружении, и от контейнера с сервером постгреса нет никакого доступа к контейнеру с файлами проекта. Выберите подходящий вам способ.

Процедура импорта в таком виде может занять довольно много времени, сходите перекусить или прогуляться.

4. Добавляем индексы. Почему сейчас, а не при создании таблицы? Потому что тогда операция импорта заняла бы гораздо больше времени (часы против минут). Индексов у нас два: уникальный для поддержки неповторяемости слов, и собственно индекс для работы триграммного движка postgresql, который всё это обеспечивает.

execute "CREATE EXTENSION IF NOT EXISTS btree_gin;"
execute "CREATE EXTENSION IF NOT EXISTS btree_gist;"
add_index :words, :word, name: 'index_words_on_word_unique', using: :btree, unique: true
execute "CREATE INDEX index_words_on_word ON words USING gist (word gist_trgm_ops);"

Я не нашёл комбинацию add_index, поддерживающую нужные параметры, поэтому навесил индекс прямым SQL-запросом. Почему бы и нет. Подскажите в комментариях, кто в курсе.

Не навешивайте validates_uniqueness в модели Word! Это будет работать гораздо медленнее.

Не ленитесь добавить индексы. Без них каждый запрос работает чуть ли по не полминуты, делая бессмысленной всю затею.

5. Делаем метод lookup в классе модели Word. Проблема в том, что когда юзер будет искать по фразе из нескольких слов, триграммный движок не сможет найти её всю. Он может искать только по словам. Поэтому, например, так:

def self.lookup(string)
words = string.split
if words.size > 1
words.map{|word| self.lookup(word) }.join(' ')
elsif words.size == 1
results = Word.all.select(:word).where('(word % ?) OR (word ILIKE ?)', string, string).order("similarity(word, #{Word.sanitize(string)}) desc").limit(1).map(&:word)
results.empty? ? string : results.first
else
'' # пустая строка
end
end

В коде случайно получился симпатичный приём из области функционального программирования.

6. Делаем контроллер, который на вход принимает кривую строку, а выдаёт “исправленную”. Это нужно для подсказок “вы имели в виду”.

7. Встраиваем в scope :search ваших моделей автоматическое задействие этого распознавания. Это нужно в случае, когда юзер введёт фразу с опечатками, а вы сразу их исправляете и делаете поиск.

Всё. Спасибо интернету за рецепты. Реализация заняла порядка 2-х часов, включая ожидание импорта словаря. Если у вас есть специфическая база продукции, в названиях которой люди тоже часто ошибаются, выполните следующий пункт:

8. Настраиваем движок проекта таким образом, чтобы при внесении или изменении названий продуктов он бы пополнял ими таблицу words.