Normalização de Pesquisa em Árabe: Falta Suporte para Variantes de Hamza, Formas de Ya/Kaf e Equivalência Ortográfica

Não sou um especialista técnico, mas tenho pesquisado este problema porque quero garantir que nenhuma consulta de pesquisa seja perdida em um fórum bilíngue Persa-Árabe no Discourse. Como o Discourse usa PostgreSQL, a normalização torna-se essencial: um usuário pode pesquisar usando caracteres persas, enquanto a mesma palavra é armazenada usando caracteres árabes — ou vice-versa. Sem a normalização adequada, a pesquisa falhará.
Com base no que aprendi, usar a normalização NFKC do Unicode é um bom ponto de partida — ela lida com muitos casos de compatibilidade, como ligaturas, formas de apresentação e dígitos árabes/persas.

No entanto, para texto persa e árabe, o NFKC sozinho não é suficiente. Ele não normaliza várias variantes de caracteres críticas que são visual e semanticamente equivalentes, mas diferem no nível binário.

Abaixo, descrevo os procedimentos e insights que cheguei através de minha pesquisa e exploração.


:wrench: Estratégia Geral de Design

  1. Aplique primeiro a normalização NFKC do Unicode para lidar com ligaturas, formas de apresentação e unificação de dígitos.
  2. Em seguida, aplique mapeamentos de caracteres personalizados em uma ordem definida (por exemplo, normalize variantes de Hamza antes de Ya árabe).
  3. Separe as políticas de normalização para armazenamento vs. pesquisa:
    • Use um perfil Conservador para armazenamento canônico (preserve ZWNJ, evite mudanças semânticas).
    • Use um perfil Permissivo para pesquisa (ignore ZWNJ, unifique variantes de Hamza, normalize dígitos).
  4. Todos os mapeamentos devem ser configuráveis através de uma tabela de mapeamento centralizada no banco de dados ou um hash Ruby na aplicação.

:one: Perfis de Normalização

:green_circle: Conservador (para armazenamento)

  • Transformação mínima
  • Aplique NFKC
  • Normalize Kaf/Ya árabe para equivalentes persas
  • Remova diacríticos
  • Preserve ZWNJ
  • Armazene como texto_original + normalizado_conservador

:blue_circle: Permissivo (para pesquisa)

  • Correspondência agressiva
  • Aplique todas as regras Conservadoras
  • Remova/ignore ZWNJ
  • Normalize variantes de Hamza para letras base
  • Converta todos os dígitos para ASCII
  • Opcionalmente, unifique Taa Marbuta → Heh
  • Usado para pré-processamento de consulta

:two: Tabela de Mapeamento Abrangente

Fonte Destino Unicode Notas
ك ک U+0643 → U+06A9 Kaf árabe → Kaf persa
ي ی U+064A → U+06CC Ya árabe → Ya persa
ى ی U+0649 → U+06CC Variante Ya final
أ, إ, ٱ ا Vários → U+0627 Formas de Hamza → Alef
ؤ و U+0624 → U+0648 Hamza Waw
ئ ی U+0626 → U+06CC Hamza Ya
ء — U+0621 Remover ou preservar (configurável)
ة ه U+0629 → U+0647 Taa Marbuta → Heh (opcional)
ۀ هٔ U+06C0 ↔ U+0647+U+0654 Normalizar forma composta
ڭ گ U+06AD → U+06AF Variantes regionais
‌ — U+200C ZWNJ: preservar em Conservador, remover em Permissivo
٤, ۴ 4 U+0664, U+06F4 → ASCII Normalizar dígitos
Diacríticos — U+064B–U+0652 Remover todos os harakat
ZWJ — U+200D Remover conectores invisíveis
Múltiplos espaços Espaço único — Normalizar espaçamento

:three: Snippet de Mapeamento Rápido (para SQL ou Ruby)

ك → ک
ي → ی
ى → ی
أ → ا
إ → ا
ؤ → و
ئ → ی
ة → ه
ۀ → هٔ
ٱ → ا
٤, ۴ → 4
ZWNJ (U+200C) → (removido em permissivo)
Harakat (U+064B..U+0652) → removido
ZWJ (U+200D) → removido

:four: Implementação em PostgreSQL

  • Crie uma tabela text_normalization_map
  • Use regexp_replace ou cadeias TRANSLATE para desempenho
  • Opcionalmente, implemente em PL/Python ou PL/v8 para suporte a Unicode
  • Normalize o conteúdo armazenado e as consultas recebidas usando a mesma lógica

Estratégia de Indexação

  • Armazene normalized_conservative para indexação canônica
  • Normalize consultas com normalize_persian_arabic(query, 'permissive')
  • Se usar pesquisa permissiva, o índice deve corresponder ao mesmo perfil
  • Opcionalmente, armazene ambas as versões para comparação cruzada

:five: Exemplo de Hash Ruby (para Discourse)

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

:six: Desempenho e Notas Práticas

  1. Aplique NFKC na camada de aplicação (por exemplo, Ruby unicode_normalize(:nfkc))
  2. Use índices separados para perfis conservador vs. permissivo
  3. Evite mapeamentos forçados de caracteres semanticamente sensíveis (por exemplo, Hamza, Taa Marbuta) a menos que explicitamente configurado
  4. Execute testes A/B em dados reais do fórum para medir a taxa de acertos e falsos positivos
  5. Documente cada mapeamento com justificativa e exemplos
  6. Defina testes de unidade em Ruby e SQL para cada mapeamento

:seven: Recomendação Final

  • Use Unicode NFKC como base
  • Estenda-o com uma camada de mapeamento personalizada
  • Mantenha perfis duplos para armazenamento e pesquisa
  • Implemente a normalização nas camadas de aplicativo e banco de dados
  • Documente e teste cada mapeamento
  • Crie índices apropriados (GIN + to_tsvector) em colunas normalizadas