No soy un experto técnico, pero he estado investigando este problema porque quiero asegurarme de que no se pierdan consultas de búsqueda en un foro bilingüe persa-árabe de Discourse. Dado que Discourse utiliza PostgreSQL, la normalización se vuelve esencial: un usuario podría buscar usando caracteres persas, mientras que la misma palabra se almacena usando caracteres árabes, o viceversa. Sin una normalización adecuada, la búsqueda fallará.
Según lo que he aprendido, usar la normalización NFKC de Unicode es un buen punto de partida: maneja muchos casos de compatibilidad como ligaduras, formas de presentación y dígitos árabes/persas.
Sin embargo, para el texto persa y árabe, NFKC por sí solo no es suficiente. No normaliza varias variantes de caracteres críticas que son visual y semánticamente equivalentes pero difieren a nivel binario.
A continuación, describo los procedimientos y las ideas a las que he llegado a través de mi investigación y exploración.
Estrategia General de Diseño
- Aplicar primero la normalización NFKC de Unicode para manejar ligaduras, formas de presentación y unificación de dígitos.
- Luego aplicar mapeos de caracteres personalizados en un orden definido (por ejemplo, normalizar variantes de Hamza antes que Ya árabe).
- Separar las políticas de normalización para almacenamiento vs. búsqueda:
- Usar un perfil Conservador para el almacenamiento canónico (preservar ZWNJ, evitar cambios semánticos).
- Usar un perfil Permisivo para la búsqueda (ignorar ZWNJ, unificar variantes de Hamza, normalizar dígitos).
- Todos los mapeos deben ser configurables a través de una tabla de mapeo centralizada en la base de datos o un hash de Ruby en la aplicación.
Perfiles de Normalización
Conservador (para almacenamiento)
- Transformación mínima
- Aplicar NFKC
- Normalizar Kaf/Ya árabe a equivalentes persas
- Eliminar diacríticos
- Preservar ZWNJ
- Almacenar como
texto_original+normalizado_conservador
Permisivo (para búsqueda)
- Coincidencia agresiva
- Aplicar todas las reglas Conservadoras
- Eliminar/ignorar ZWNJ
- Normalizar variantes de Hamza a letras base
- Convertir todos los dígitos a ASCII
- Opcionalmente unificar Taa Marbuta → Heh
- Usado para preprocesamiento de consultas
Tabla de Mapeo Integral
| Fuente | Destino | Unicode | Notas |
|---|---|---|---|
ك |
ک |
U+0643 → U+06A9 | Kaf árabe → Kaf persa |
ي |
ی |
U+064A → U+06CC | Ya árabe → Ya persa |
ى |
ی |
U+0649 → U+06CC | Variante final de Ya |
أ, إ, ٱ |
ا |
Varios → U+0627 | Formas de Hamza → Alef |
ؤ |
و |
U+0624 → U+0648 | Hamza Waw |
ئ |
ی |
U+0626 → U+06CC | Hamza Ya |
ء |
— | U+0621 | Eliminar o preservar (configurable) |
ة |
ه |
U+0629 → U+0647 | Taa Marbuta → Heh (opcional) |
ۀ |
هٔ |
U+06C0 ↔ U+0647+U+0654 | Normalizar forma compuesta |
ڭ |
گ |
U+06AD → U+06AF | Variantes regionales |
|
— | U+200C | ZWNJ: preservar en Conservador, eliminar en Permisivo |
٤, ۴ |
4 |
U+0664, U+06F4 → ASCII | Normalizar dígitos |
| Diacríticos | — | U+064B–U+0652 | Eliminar todos los harakat |
| ZWJ | — | U+200D | Eliminar uniones invisibles |
| Espacios múltiples | Espacio simple | — | Normalizar espaciado |
Fragmento de Mapeo Rápido (para SQL o Ruby)
ك → ک
ي → ی
ى → ی
أ → ا
إ → ا
ؤ → و
ئ → ی
ة → ه
ۀ → هٔ
ٱ → ا
٤, ۴ → 4
ZWNJ (U+200C) → (eliminado en permisivo)
Harakat (U+064B..U+0652) → eliminado
ZWJ (U+200D) → eliminado
Implementación en PostgreSQL
- Crear una tabla
text_normalization_map - Usar
regexp_replaceo cadenasTRANSLATEpara rendimiento - Opcionalmente implementar en PL/Python o PL/v8 para soporte Unicode
- Normalizar tanto el contenido almacenado como las consultas entrantes utilizando la misma lógica
Estrategia de Indexación
- Almacenar
normalized_conservativepara indexación canónica - Normalizar consultas con
normalize_persian_arabic(query, 'permissive') - Si se usa búsqueda permisiva, el índice debe coincidir con el mismo perfil
- Opcionalmente almacenar ambas versiones para comparación cruzada
Ejemplo de Hash de Ruby (para Discourse)
NORMALIZATION_MAP = {
"ك" => "ک",
"ي" => "ی",
"ى" => "ی",
"أ" => "ا",
"إ" => "ا",
"ٱ" => "ا",
"ؤ" => "و",
"ئ" => "ی",
"ة" => "ه",
"ۀ" => "هٔ",
"۴" => "4",
"٤" => "4",
"\u200C" => "", # ZWNJ
"\u200D" => "", # ZWJ
}
Notas de Rendimiento y Prácticas
- Aplicar NFKC en la capa de aplicación (por ejemplo,
unicode_normalize(:nfkc)de Ruby) - Usar índices separados para perfiles conservadores vs. permisivos
- Evitar mapeos forzados de caracteres semánticamente sensibles (por ejemplo, Hamza, Taa Marbuta) a menos que se configuren explícitamente
- Ejecutar pruebas A/B con datos reales del foro para medir la tasa de aciertos y los falsos positivos
- Documentar cada mapeo con justificación y ejemplos
- Definir pruebas unitarias tanto en Ruby como en SQL para cada mapeo
Recomendación Final
- Usar Unicode NFKC como base
- Extenderlo con una capa de mapeo personalizada
- Mantener perfiles duales para almacenamiento y búsqueda
- Implementar la normalización tanto en la capa de la aplicación como en la de la base de datos
- Documentar y probar cada mapeo
- Construir índices apropiados (
GIN + to_tsvector) en columnas normalizadas