Нормализация поиска на арабском: отсутствует поддержка вариантов хамзы, форм я/каф и орфографической эквивалентности

Я не технический эксперт, но я изучал эту проблему, так как хочу убедиться, что ни один поисковый запрос не будет упущен на двуязычном форуме на персидском и арабском языках, работающем на Discourse. Поскольку Discourse использует PostgreSQL, нормализация становится критически важной: пользователь может вводить запрос на персидском языке, в то время как то же самое слово хранится на арабском, или наоборот. Без правильной нормализации поиск не сработает.

Судя по моим исследованиям, использование нормализации Unicode NFKC — это надежная отправная точка: она обрабатывает многие случаи совместимости, такие как лигатуры, формы представления и арабские/персидские цифры.

Однако для персидского и арабского текста одного NFKC недостаточно. Он не нормализует несколько критических вариантов символов, которые визуально и семантически эквивалентны, но различаются на бинарном уровне.

Ниже я излагаю процедуры и выводы, к которым пришел в ходе своих исследований и экспериментов.


:wrench: Общая стратегия проектирования

  1. Сначала применить нормализацию Unicode NFKC для обработки лигатур, форм представления и унификации цифр.
  2. Затем применить пользовательские маппинги символов в определенном порядке (например, нормализовать варианты хамзы перед арабской Ya).
  3. Разделить политики нормализации для хранения и поиска:
    • Использовать консервативный профиль для канонического хранения (сохранять ZWNJ, избегать семантических сдвигов).
    • Использовать разрешительный профиль для поиска (игнорировать ZWNJ, унифицировать варианты хамзы, нормализовать цифры).
  4. Все маппинги должны быть настраиваемыми через централизованную таблицу маппингов в базе данных или хэш Ruby в приложении.

:one: Профили нормализации

:green_circle: Консервативный (для хранения)

  • Минимальное преобразование
  • Применение NFKC
  • Нормализация арабского Kaf/Ya до персидских эквивалентов
  • Удаление диакритических знаков
  • Сохранение ZWNJ
  • Хранение как original_text + normalized_conservative

:blue_circle: Разрешительный (для поиска)

  • Агрессивное совпадение
  • Применение всех правил консервативного профиля
  • Удаление/игнорирование ZWNJ
  • Нормализация вариантов хамзы до базовых букв
  • Преобразование всех цифр в ASCII
  • Опциональная унификация Taa Marbuta → Heh
  • Используется для предварительной обработки запросов

:two: Полная таблица маппингов

Источник Цель Unicode Примечания
ك ک U+0643 → U+06A9 Арабский Kaf → Персидский Kaf
ي ی U+064A → U+06CC Арабский Ya → Персидский Ya
ى ی U+0649 → U+06CC Вариант конечной Ya
أ, إ, ٱ ا Различные → U+0627 Формы хамзы → Алеф
ؤ و U+0624 → U+0648 Хамза Waw
ئ ی U+0626 → U+06CC Хамза Ya
ء — U+0621 Удалить или сохранить (настраивается)
ة ه U+0629 → U+0647 Taa Marbuta → Heh (опционально)
ۀ هٔ U+06C0 ↔ U+0647+U+0654 Нормализовать составную форму
ڭ گ U+06AD → U+06AF Региональные варианты
‌ — U+200C ZWNJ: сохранять в консервативном, удалять в разрешительном
٤, ۴ 4 U+0664, U+06F4 → ASCII Нормализация цифр
Диакритика — U+064B–U+0652 Удалить все харака
ZWJ — U+200D Удалить невидимые соединители
Несколько пробелов Один пробел — Нормализация пробелов

:three: Быстрый фрагмент маппинга (для SQL или Ruby)

ك → ک
ي → ی
ى → ی
أ → ا
إ → ا
ؤ → و
ئ → ی
ة → ه
ۀ → هٔ
ٱ → ا
٤, ۴ → 4
ZWNJ (U+200C) → (удаляется в разрешительном режиме)
Харака (U+064B..U+0652) → удаляются
ZWJ (U+200D) → удаляются

:four: Реализация в PostgreSQL

  • Создать таблицу text_normalization_map
  • Использовать цепочки regexp_replace или TRANSLATE для производительности
  • Опционально реализовать на PL/Python или PL/v8 для поддержки Unicode
  • Нормализовать как сохраненный контент, так и входящие запросы, используя одну и ту же логику

Стратегия индексации

  • Хранить normalized_conservative для канонической индексации
  • Нормализовать запросы с помощью normalize_persian_arabic(query, 'permissive')
  • При использовании разрешительного поиска индекс должен соответствовать тому же профилю
  • Опционально хранить обе версии для кросс-сравнения

:five: Пример хэша Ruby (для Discourse)

NORMALIZATION_MAP = {
  "ك" => "ک",
  "ي" => "ی",
  "ى" => "ی",
  "أ" => "ا",
  "إ" => "ا",
  "ٱ" => "ا",
  "ؤ" => "و",
  "ئ" => "ی",
  "ة" => "ه",
  "ۀ" => "هٔ",
  "۴" => "4",
  "٤" => "4",
  "\u200C" => "", # ZWNJ
  "\u200D" => "", # ZWJ
}

:six: Заметки о производительности и практике

  1. Применять NFKC на уровне приложения (например, Ruby unicode_normalize(:nfkc))
  2. Использовать отдельные индексы для консервативного и разрешительного профилей
  3. Избегать принудительных маппингов семантически чувствительных символов (например, хамза, Taa Marbuta), если это явно не настроено
  4. Проводить A/B-тесты на реальных данных форума для оценки процента попаданий и ложных срабатываний
  5. Документировать каждое маппинг с обоснованием и примерами
  6. Определить юнит-тесты на Ruby и SQL для каждого маппинга

:seven: Итоговая рекомендация

  • Использовать Unicode NFKC как основу
  • Расширить его пользовательским слоем маппингов
  • Поддерживать два профиля для хранения и поиска
  • Реализовать нормализацию как на уровне приложения, так и на уровне базы данных
  • Документировать и тестировать каждое маппинг
  • Создать соответствующие индексы (GIN + to_tsvector) на нормализованных колонках