Freedam
IngenieríaParte 4 de 7 · 28 min de lecturaActualizado el

Buscar en una biblioteca en veinte idiomas

Activar un segundo idioma en un gestor de recursos digitales cambia la interfaz y los formularios de metadatos, mientras que la búsqueda puede parecer que sigue funcionando. En nuestro caso, esa apariencia ocultaba una alternativa que seleccionaba un corpus distinto del solicitado, sin producir ningún error ni aviso.

Este es el tercer artículo de una serie sobre la búsqueda dentro de freedam. El primero trató la arquitectura de recuperación: construir un documento buscable, fusionar BM25 con pgvector y manejar los topes de candidatos. El segundo examinó el hash perceptual. Este vuelve al texto y pregunta qué idioma usa cada índice.

La respuesta depende de varios valores repartidos en tres lugares, entre ellos dos valores fijados en el DDL. Cuando discrepan, las consultas pueden devolver resultados incompletos o vacíos aun siendo sintácticamente válidas, y ese es el fallo que examina este artículo.

Un índice de búsqueda de texto tiene un idioma

La búsqueda de texto completo no es una simple coincidencia de cadenas. Una configuración de búsqueda de texto aporta el analizador, la lista de stopwords y el stemmer que se aplican entre el texto almacenado y la consulta, de modo que elegir una cambia los términos que se guardan físicamente en el índice.

Con la configuración english, running shoes se convierte en los lexemas run y shoe, así que una búsqueda de run puede encontrarlo. Con simple, que no aplica stemming, la misma frase sigue siendo running y shoes, así que run no encuentra nada. El texto y la consulta son los mismos, pero la configuración usada al indexar cambia la respuesta.

freedam admite treinta idiomas de interfaz, mientras que PostgreSQL 18 ofrece configuraciones Snowball adecuadas para veinte de ellos; los otros diez usan simple.

Treinta idiomas de la aplicación asignados a veinte stemmers de PostgreSQL TREINTA IDIOMAS DE INTERFAZ ar da de el en es et fi fr hu id it lt nb nl pt ro ru sv tr bg cs ja ko lv pl sk sl uk zh continuo: existe un stemmer Snowball discontinuo: usa la configuración `simple` POSTGRESQL 18 TRAE 29 CONFIGS CON STEMMER 20 son accesibles desde un idioma de interfaz y reciben un índice real. 9 existen y nunca se usan: armenian, basque, catalan, hindi, irish, nepali, serbian, tamil, yiddish. Ningún idioma de interfaz se asigna a ellas. Ninguno de los 10 códigos discontinuos tiene una. LOS DIEZ NO SON UN DESCUIDO Eslavas aparte del ruso, bálticas aparte del lituano, y toda escritura CJK. PostgreSQL no incluye ningún stemmer para ellas, así que no hay nada que asignar. La sección de almacenamiento explica el costo. La asignación vive en una función PHP y se proyecta en tres lugares. Mantenerlos de acuerdo es el tema de este artículo.
Código de idioma a configuración de búsqueda de texto. Veinte idiomas obtienen una coincidencia que tiene en cuenta la flexión; diez obtienen una coincidencia de forma exacta y dependen de que otras partes del pipeline los rescaten, o no.

La asignación empezó con siete stemmers y creció hasta veinte. Afecta al DDL de una columna generada, a cada índice BM25 y a un ajuste que se lee al consultar, así que ampliarla exige también reconstruir las partes que dependen del DDL. Un tsvector almacenado con english y una consulta compilada con simple siguen siendo válidos por separado, pero ya no contienen términos que coincidan.

Traducir el documento y no la consulta

Hay dos maneras de hacer multilingüe un índice de búsqueda, y la elección determina todo lo que viene después.

La primera opción es traducir la consulta. Una búsqueda en francés de chaussures (zapatos) se convierte en shoes y se ejecuta contra un único corpus en inglés. Esto ahorra almacenamiento, pero pone la traducción en la ruta crítica, puede tratar mal el vocabulario propio de una marca e introduce ambigüedad antes del ranking.

La segunda opción es construir un documento buscable por recurso e idioma a partir de traducciones que ya están en el sistema y buscar en el documento que corresponde al idioma del usuario. Es lo que hace freedam. El almacenamiento y el trabajo de reconstrucción crecen de forma lineal con los idiomas activos, pero las consultas no se traducen y los términos controlados proceden de traducciones aprobadas por personas.

Cada recurso tiene por tanto un documento de búsqueda almacenado por idioma activo. El primer artículo explica cómo se construye ese documento; lo que importa en lo multilingüe es qué partes varían según el idioma y cuáles no.

Un recurso produce un documento por idioma pero un solo embedding un recurso metadatos, vocabulario, OCR, leyendas, pistas document · en indexado con el stemmer english document · fr indexado con el stemmer french document · ja indexado con simple: sin stemming un vector de 768 dim. sin columna de idioma alguna VARÍA SEGÚN EL IDIOMA etiquetas de términos de vocabulario y toda su ruta de ancestros, por traducción valores de metadatos multilingües qué pista de subtítulos se prefiere y el stemmer aplicado a todo ello IDÉNTICO EN CADA DOCUMENTO nombre de archivo, título, EXIF e IPTC texto OCR, leyenda de IA, etiquetas de IA campos de metadatos de un solo idioma así que gran parte del texto de un documento francés no es francés. Se mide a continuación. Al desactivar un idioma se eliminan sus documentos y su índice; el vector no cambia, nunca perteneció a un idioma.
La mitad léxica del pipeline se multiplica con los idiomas. La mitad semántica no. Esa asimetría es deliberada, y ahí están los límites reales del diseño.

El vocabulario controlado es lo que hace que merezca la pena el almacenamiento. Un término de taxonomía se guarda una vez con un código y se traduce a todos los idiomas activos, de modo que el documento en francés contiene la etiqueta en francés, su ruta de ancestros en francés y nada en inglés. Un usuario que busca chaussures de course (zapatillas de running) coincide con un término cuyo código almacenado podría ser algo como footwear.running, porque una persona aprobó esa traducción una sola vez, en un editor de vocabulario controlado, y todos los recursos que llevan el término la heredaron.

El resto del texto no se traduce: los nombres de archivo siguen siendo nombres de archivo, el EXIF viene de la cámara, el OCR conserva el texto del embalaje y las descripciones de IA se generan una sola vez en el idioma predeterminado. Ese contenido se copia en el documento de cada idioma, así que un documento en francés puede seguir conteniendo sobre todo texto en inglés procesado por un stemmer francés.

Esto es menos dañino de lo que parece al principio, siempre que el índice y la consulta usen la misma configuración.

Qué hace un stemmer con texto de otro idioma

Toma una frase de vocabulario corriente de producto en inglés y pásala por cuatro configuraciones. Todo lo que sigue es salida de to_tsvector en PostgreSQL 18, no una descripción de ella.

Con english, running shoes bags images packaging se convierte en run, shoe, bag, imag, packag. Con french, la misma entrada da running, sho, bag, imag, packaging. Con german, running, shos, bag, imag, packaging. Con simple, no cambia nada.

El stemmer francés produce sho a partir de shoes, que no es una palabra en ningún idioma. No importa. Lo que importa es que la consulta pase por la misma configuración: un usuario con el idioma francés que busca shoes también produce sho, y ambos coinciden. De nueve consultas en inglés probadas contra ese documento, ocho coincidieron con la configuración francesa exactamente igual que con la inglesa. El único fallo fue run, porque el francés no tiene ninguna regla que elimine -ing, así que running queda entero y la consulta más corta no puede alcanzarlo.

La lección práctica es que la coherencia entre el índice y la consulta importa más que elegir la configuración ideal para cada token. Aplicar el mismo stemmer imperfecto a ambos lados tiende a degradarse hacia la coincidencia de forma exacta, mientras que aplicar configuraciones distintas hace que los términos dejen de coincidir. El error de la alternativa que se describe más abajo es un ejemplo de lo segundo.

El stemmer equivocado, sin embargo, no sale gratis, y el costo aparece donde menos se espera: en la longitud del documento.

El mismo texto en inglés con cuatro configuraciones de búsqueda de texto running shoes bags images packaging english run shoe bag imag packag french running sho bag imag packaging german running shos bag imag packaging simple running shoes bags images packaging el idioma del texto 8 de 9 consultas siguen coincidiendo 8 de 9 consultas siguen coincidiendo solo forma exacta LEXEMAS DISTINTOS POR DOCUMENTO EN PROMEDIO, 2000 DOCUMENTOS REALES english 192,0 french 207,9 +8,3 % simple 220,6 +14,9 % Los mismos bytes. Las stopwords inglesas no lo son en francés, así que the, of, and, with sobreviven como lexemas, y las flexiones sin reducir ya no colapsan entre sí. BM25 normaliza por longitud de documento y las puntuaciones cambian.
Medido sobre 2000 documentos de una réplica de producción de 22.944 recursos. Elegir una configuración no solo cambia qué términos coinciden; cambia la longitud de cada documento y, por tanto, su posición en el ranking.

Hay además un efecto sobre la longitud del documento. En 2000 documentos, to_tsvector('english', …) produjo 192,0 lexemas distintos de media, frente a 207,9 con french para el mismo contenido. Con la normalización de longitud de BM25 en b = 0.75, la versión francesa puntúa por tanto de otra manera aunque el texto subyacente sea idéntico.

Esa diferencia es coherente dentro de un corpus francés, pero importa cuando se combinan resultados de varios corpus de idiomas.

Dos comportamientos más que conviene conocer antes de dar por hecho que un stemmer resuelve un idioma:

El alemán no descompone palabras compuestas. Produktfotografie se reduce al único lexema produktfotografi. Produkt Fotografie, escrito con espacio, se reduce a dos. Un usuario alemán que busque Produkt no encontrará el compuesto, y ninguna configuración en alemán lo cambia, porque Snowball elimina sufijos pero no separa palabras. Los idiomas con muchos compuestos necesitan un diccionario de descomposición, que PostgreSQL admite mediante diccionarios ispell y que por ahora no incluimos.

El turco y el ruso funcionan muy bien. fotoğraflarımızda, con cinco morfemas de profundidad, se reduce limpiamente a fotoğraf. El ruso фотографии y фотография se reducen ambos a фотограф, que es exactamente el colapso que se quiere de un idioma muy flexivo. La aglutinación y la flexión son aquello en lo que Snowball es bueno. La composición es aquello en lo que no.

Un índice parcial por idioma

freedam no construye un único índice de texto completo sobre asset_search_documents. Construye uno por idioma activo, cada uno de ellos un índice parcial restringido a las filas de ese idioma.

La sentencia, con los parámetros reales del tenant interpolados, tiene este aspecto: CREATE INDEX assets_bm25_fr_idx ON asset_search_documents USING bm25(content) WITH (text_config='french', k1=1.2, b=0.75) WHERE language_code = 'fr'.

La sentencia une tres valores, aunque solo uno es visible en el nombre del índice.

El nombre se deriva del código de idioma. La configuración de texto se deriva del código de idioma mediante el mapa de ajustes. El predicado es el propio código de idioma. Para usar este índice, una consulta debe aportar un WHERE language_code = 'fr' que PostgreSQL pueda demostrar que se deduce del predicado del índice, y debe compilar su tsquery con french, o los términos que busca no serán los que se almacenaron.

Otros dos índices relacionados usan claves distintas por razones prácticas.

Cuatro estructuras de índice, con tres claves distintas índice BM25 de contenido · uno por IDIOMA assets_bm25_<code>_idx parcial: WHERE language_code = <code> lleva text_config, k1 = 1.2, b = 0.75 8,1 MB para 22.944 documentos en inglés tsvector · una COLUMNA para todos los idiomas content_tsv GENERATED ALWAYS AS … un CASE sobre language_code elige la config, fijada en el DDL al migrar índice GIN de 9,3 MB sobre la columna índice de títulos · uno por CONFIG DE TEXTO assets_title_tsv_<config>_idx sobre assets, no sobre la tabla de documentos los 10 idiomas sin stemmer comparten un índice 520 kB · un índice funcional exige igualdad exacta índice vectorial · UNO, sin idioma alguno assets_ai_vector_hnsw_idx HNSW, coseno, m = 16, ef_construction = 200 sobre assets.ai_vector, 768 dimensiones no crece al activar un idioma Tres claves para un concepto. Una errónea crea un índice válido, mantenido y sin uso; solo se nota en la latencia.
Tamaños medidos en un tenant de un solo idioma con 22.944 recursos y 30 MB de texto de documentos. Todo lo que hay en los dos recuadros azules se multiplica con cada idioma activado.

El índice de títulos se define por configuración y no por idioma, porque un índice funcional solo sirve a un predicado cuya expresión, configuración incluida, sea idéntica. Los veinte idiomas con stemmer se asignan a configuraciones distintas, mientras que los diez sin stemmer usan todos simple; definirlo por configuración evita construir diez índices equivalentes sobre la misma columna.

Esto significa también que la creación del índice no puede depender de un ajuste del tenant que no está disponible durante las migraciones. En ese contexto la configuración se resolvía como simple, mientras que las consultas de producción usaban una expresión english, lo que dejaba un índice válido y mantenido que las consultas no podían usar. El síntoma era un escaneo secuencial de la tabla de recursos, medido en unos 26 ms sobre el corpus de 22.944 recursos. Cambiamos el punto de entrada que construye el índice para exigir una configuración explícita en los contextos donde el mapa del tenant no se puede resolver.

Hay un tercer índice, y crea las condiciones del error de la alternativa que sigue: ninguno de estos índices es accesible a menos que la consulta nombre el idioma correcto en tres lugares a la vez.

Una alternativa que seleccionaba el corpus equivocado

El error comenzó cuando un usuario buscó con el idioma fr antes de que existiera un índice en francés. La búsqueda del índice recurrió al nombre del índice en inglés, pero el predicado de idioma y la configuración de texto no recurrieron a su alternativa con él.

Cada alternativa por separado parecía razonable, pero los tres valores derivados ya no describían el mismo corpus.

Usar la alternativa solo en el nombre del índice deja atrás el predicado y la configuración de texto ANTES · UN VALOR USÓ LA ALTERNATIVA idioma solicitado fr nombre del índice assets_bm25_en_idx WHERE language_code = 'fr' config de tsquery french un índice inglés pidió filas francesas DESPUÉS · RESOLVER UNA VEZ, DERIVAR TRES idioma solicitado fr idioma efectivo en nombre del índice assets_bm25_en_idx WHERE language_code = 'en' config de tsquery english DOS FORMAS DE FALLO, AMBAS SILENCIOSAS 1 · el índice y el predicado discrepan Un índice parcial de filas inglesas no puede atender un predicado que pide filas francesas. El escaneo no devuelve nada. Resultado: cero filas. Sin error ni aviso. 2 · config almacenada y de consulta discrepan El corpus se procesó de una forma y la consulta se compiló de otra. La cobertura de términos rechaza documentos que sí contienen el término. Resultado: recall parcial que parece ranking. Resuelve primero el idioma efectivo y deriva del mismo valor el índice, el predicado y la configuración de texto.
La corrección es estructural y no ingeniosa: resolver el idioma efectivo una sola vez, arriba, y derivar de ese único valor resuelto el nombre del índice, el predicado y la configuración de texto.

Con el predicado y la configuración equivocados, la consulta no devuelve nada. Corregir solo el predicado llega al corpus en inglés pero sigue compilando la consulta con la configuración equivocada, lo que produce un fallo parcial que las comprobaciones puntuales sencillas pueden pasar por alto.

Podemos medir ese residuo. Toma el corpus en inglés, 22.944 documentos indexados con la configuración english, y consúltalo con un tsquery compilado con simple, que es lo que produce un idioma sin resolver cuando no tiene entrada en el mapa de configuraciones. Cuenta las coincidencias en ambos casos:

  • images coincide con 19.029 documentos correctamente y con 0 con la configuración que no coincide.
  • bags coincide con 1236 y con 0.
  • packaging coincide con 371 y con 0.
  • shoes coincide con 4 y con 0.
  • leather coincide con 6171 y con 6171.
  • campaign coincide con 8 y con 8.

Los dos últimos términos muestran por qué el error sobrevivió a las pruebas: una discrepancia es invisible cuando la raíz coincide con la forma original. Una buena comprobación puntual necesita por tanto un término flexionado como images, no solo un sustantivo cuya forma no cambia.

Para saber qué parte del corpus abarca eso, tomamos los 500 términos en inglés más frecuentes del diccionario de búsqueda del tenant, de al menos tres letras, y comparamos la raíz english de cada término con su forma simple. 281 de los 500 difieren, un 56,2 %. Ponderado por el número de documentos en que aparece cada término, 693.029 de 1.240.305 referencias a documentos, un 55,9 %. Así que algo más de la mitad del tráfico real de consultas no devuelve nada, algo menos de la mitad se comporta a la perfección y ninguna línea de registro distingue ambos casos.

El mismo defecto apareció en la búsqueda híbrida y en el autocompletado porque ambos recurrían a un nombre de índice alternativo sin resolver antes el idioma efectivo. Sustituimos el auxiliar estrecho del nombre del índice por uno que resuelve el idioma como una unidad y deriva de ese resultado el nombre del índice, el predicado y la configuración de texto. Así es más difícil que un punto de llamada actualice solo uno de los tres valores.

La regla reutilizable es aplicar la alternativa al valor de origen compartido y recalcular todo lo que se deriva de él.

Fusionar posiciones de dos corpus de idiomas

Resolver el idioma resuelve la corrección. No resuelve el problema real del producto, que es que un usuario francés en una biblioteca cuyos metadatos están sobre todo en inglés debería encontrar igualmente las cosas.

Así que la recuperación ejecuta dos ramas cuando el idioma efectivo del usuario difiere del predeterminado del tenant: una sobre el corpus del usuario y otra sobre el corpus predeterminado. Cada rama es un escaneo BM25 completo e independiente contra su propio índice parcial, con su propia configuración de texto y su propia verificación de cobertura de términos. Después se fusionan las dos.

La fusión se hace a nivel de posiciones y no de puntuaciones, y esa decisión merece la misma defensa que recibió la fusión por posición recíproca en el primer artículo. Dos puntuaciones BM25 de dos índices no son comparables con ningún criterio razonable. Se calcularon sobre corpus distintos, con longitudes medias de documento distintas, a partir de distribuciones de lexemas distintas y con un término de frecuencia inversa de documento calibrado sobre vocabularios distintos. Sumarlas, promediarlas o aplicarles un umbral conjunto sería aritmética sobre unidades incompatibles. Las posiciones son ordinales, comparables por construcción, y son lo que consume de todos modos la etapa de fusión posterior.

Dos ramas de idioma fusionadas por la mejor posición de escaneo y recortadas de nuevo rama A · idioma del usuario índice assets_bm25_fr_idx config french, predicado 'fr' deduplicado a top-k por posición rama B · idioma predeterminado índice assets_bm25_en_idx config english, predicado 'en' deduplicado a top-k por posición fusión UNION ALL GROUP BY asset_id MIN(best_pos) conserva el mejor puesto nuevo tope en top-k dos ramas con tope gastarían de otro modo el doble del presupuesto de candidatos después Si el idioma efectivo del usuario ES el predeterminado, la segunda rama desaparece en lugar de duplicarse: la fusión depende de que los idiomas difieran DESPUÉS de resolver, así que un locale sin índice queda en una sola rama.
Las posiciones de escaneo no son comparables entre corpus en ningún sentido absoluto, pero tampoco lo eran las puntuaciones brutas. Tomar el mínimo significa que un recurso que se encuentra en cualquiera de los dos idiomas queda ordenado según el idioma que lo encontró mejor.

Tres detalles mantienen las dos ramas coherentes con el resto del pipeline de recuperación.

Ambas ramas deben deduplicarse antes de la unión. Un escaneo ordenado de un índice BM25 devuelve aproximadamente tres filas por documento coincidente, un artefacto de cómo salen las posiciones del índice. Deduplicar después de la unión funciona, pero entonces el tope se aplica a un conjunto redundante en tres cuartas partes, así que cada rama se deduplica primero hasta su propio top-k.

El conjunto fusionado se vuelve a recortar. De lo contrario, dos ramas pueden producir el doble del presupuesto de candidatos, mientras que la fusión posterior, los umbrales de puntuación y el recuento de completitud esperan un único presupuesto. Volver a aplicar el tope mantiene ese contrato.

La misma fusión sirve a la búsqueda híbrida y a la solo léxica. La fusión de dos ramas existió al principio solo en la ruta híbrida, de modo que si el servicio de embeddings no estaba disponible, los usuarios de un idioma no predeterminado perdían también la rama léxica del idioma predeterminado. Mover la fusión a un constructor compartido mantiene ambas rutas sobre los mismos corpus.

El idioma efectivo y su configuración derivada se resuelven una vez por ejecución de búsqueda y se pasan por la compilación de reglas, la recuperación, la generación de prefijos, las sugerencias y los reintentos por erratas. Esto impide que un cambio de ajustes durante una petición haga que distintas etapas construyan la misma consulta a partir de estados de idioma distintos.

La rama vectorial, neutra en cuanto al idioma

Cada recurso tiene un vector de 768 dimensiones almacenado sin columna de idioma. Es una decisión deliberada de almacenamiento, y hace que la rama semántica sea asimétrica respecto a los índices léxicos por idioma.

El texto para el embedding se compila una vez, en el idioma predeterminado del tenant, y se envía a un modelo nomic-embed-text-v1.5 alojado en local con el prefijo de tarea search_document:. Las consultas pasan por el mismo modelo con search_query:. Ambos se quedan dentro del despliegue, lo que explica en buena parte por qué todo el sistema sigue siendo práctico de autoalojar: ningún contenido de recursos sale de la infraestructura para generar embeddings.

Así que, para un usuario francés, la búsqueda que se ejecuta es de verdad asimétrica. La rama léxica busca en un corpus francés y en uno inglés, ambos con el stemming adecuado, y los fusiona. La rama semántica genera el embedding de una consulta en francés con un modelo cuyo entrenamiento es abrumadoramente en inglés y la compara con un vector derivado de texto de documento en inglés.

Lo que eso aporta es real pero limitado. Los espacios de embeddings entrenados sobre todo en inglés sí contienen cierta estructura entre idiomas, así que una consulta en francés no es ruido, y la rama semántica sigue sacando a la luz recursos cuya descripción en inglés es conceptualmente cercana. Lo que no aporta es paridad. Una consulta en francés contra un corpus francés obtiene una excelente recuperación léxica y una recuperación semántica mediocre, mientras que una consulta en inglés en el mismo tenant obtiene ambas. Existe el ajuste para construir embeddings por idioma y está desactivado por defecto, porque activarlo multiplica el costo de embeddings y el almacenamiento por el número de idiomas activos para mejorar la mitad más débil de un pipeline cuya mitad más fuerte ya maneja bien el idioma.

En la práctica, la búsqueda semántica de freedam acepta consultas multilingües pero no ofrece la misma calidad en todos los idiomas. La paridad real exige un modelo de embeddings multilingüe, aunque la columna vectorial, el índice HNSW, la fusión y los umbrales de alrededor seguirían siendo los mismos.

Idiomas que el analizador predeterminado no puede indexar bien

Diez de los treinta idiomas de interfaz usan simple. Para checo, polaco, eslovaco, esloveno, búlgaro, ucraniano y letón, eso significa una coincidencia de forma exacta en idiomas con sistemas de casos muy ricos, de modo que el usuario debe escribir la misma forma flexionada que aparece en los metadatos. Es una degradación real y leve; lo que suele escribir una persona es un sustantivo en nominativo.

Para chino, japonés y coreano, la limitación es más grave porque afecta a los límites de los tokens y no al stemming.

El analizador predeterminado de PostgreSQL encuentra los límites de los tokens mediante espacios en blanco y signos de puntuación. El chino y el japonés se escriben sin espacios entre palabras. Así que el analizador no produce tokens mal reducidos; produce un solo token para toda una frase.

Qué sobrevive en cada familia de escritura y dónde se detienen las alternativas para CJK CORPUS TÉRMINO EXACTO FLEXIONADO SUBCADENA VECTOR Escritura latina, con stemmer ✓✓✓✓ Cirílico, stemmer ruso ✓✓✓~ Polaco, checo: simple, sin stemmer ✓✗✓~ Chino, japonés: sin límites de palabra solo la frase✗~~ MEDIDO · 16 CARACTERES DE TEXTO DE PRODUCTO EN CHINO to_tsvector('simple', …) produce exactamente 1 lexema para toda la frase de 16 caracteres. Una consulta con la palabra de 3 caracteres que significa «zapatillas» no coincide: los lexemas no son iguales. La similitud de trigramas entre ambos es 0,050. La alternativa de erratas exige de 0,5 a 0,7 según la longitud. «Los trigramas y los vectores lo cubrirán» es una esperanza, no una alternativa. ILIKE lo halla; ningún índice.
La fila inferior es la que importa. En CJK el fallo no es un ranking más débil, sino que el término que escribió el usuario no es en absoluto un token del índice.

Una frase china de dieciséis caracteres que describe una campaña de zapatillas de cuero de otoño produce un lexema con simple. Buscar la palabra de tres caracteres para zapatillas no encuentra nada porque tsquery compara lexemas completos. La similitud de trigramas es de 0,050 frente a un umbral de erratas de entre 0,5 y 0,7, así que esa vía tampoco ayuda, mientras que una consulta de subcadena sí encuentra el texto. El dato está ahí, pero los índices configurados no pueden recuperar la subfrase.

No hemos publicado una corrección para esto. Los tres enfoques viables, en orden aproximado de costo operativo, son:

Una extensión de tokenizador específica. pg_bigm indexa bigramas de caracteres y funciona en CJK donde la similitud de trigramas no. Es una extensión real con implicaciones reales de mantenimiento para un despliegue autoalojado, y cambia lo que significa «un token» para todos los idiomas de la base de datos, no solo para los CJK.

Segmentar al indexar. Pasar el texto en chino y japonés por un segmentador antes de que llegue al constructor de documentos e insertar espacios. Esto deja PostgreSQL totalmente estándar, lo que importa mucho para un producto que las personas ejecutan en su propia infraestructura, y traslada el problema a un lugar donde se puede probar: la salida de un segmentador es inspeccionable y sus fallos se ven en el documento y no en un plan de consulta. También tiene que ejecutarse sobre la consulta, y ambos lados tienen que seguir de acuerdo, que es la misma clase de restricción de la que trata todo este artículo.

Usar un modelo de embeddings multilingüe. Esto permite que las consultas CJK lleguen a los recursos por la rama semántica cuando la recuperación léxica no los encuentra. Es el cambio de código más pequeño pero la garantía más débil, porque la recuperación semántica es aproximada y los identificadores exactos no deberían depender de la distancia coseno.

Hasta que se implemente una de ellas, el producto debe decir que la búsqueda léxica en CJK es limitada y no dar a entender paridad con idiomas que tienen una tokenización adecuada.

Costo de almacenamiento y activación de idiomas

Todo lo anterior se paga en almacenamiento y en tiempo de reconstrucción, y ambos crecen de forma lineal con el número de idiomas activos.

En el tenant usado para estas mediciones, 22.944 documentos en inglés ocupan 30 MB de texto, con un índice BM25 de 8,1 MB, un índice GIN de 9,3 MB sobre el tsvector almacenado, un índice de títulos de 520 kB y 21.692 términos en el diccionario de erratas. Estas estructuras son por idioma, así que activar otro idioma las duplica aunque los metadatos traducidos sean idénticos, porque la configuración de texto aún puede diferir. Es una proyección a partir del esquema y no una medición de un tenant multilingüe.

El índice vectorial, la tabla de recursos, el almacenamiento de archivos y las versiones derivadas (renditions) no crecen con el número de idiomas. El costo adicional está en los índices de texto y los documentos, que es pequeño en comparación con los archivos multimedia de este corpus.

La activación de un idioma se ejecuta como un flujo ordenado en una cola y no como una migración.

Activar un idioma actualiza el mapa de idioma a configuración y luego lanza una reconstrucción solo para ese idioma. La reconstrucción recorre la tabla de recursos en segmentos acotados y encadena por sí misma una continuación desde el siguiente id de recurso en lugar de ejecutar una sola pasada larga, de modo que un tenant grande queda cubierto por una cadena de trabajos cortos que se pueden reintentar de forma individual. Solo el segmento que termina el rango lanza la construcción del índice, que lo elimina y lo recrea en lugar de crearlo, porque la extensión BM25 puede conservar un estado interno corrupto cuando se crea un índice sobre una tabla vacía y se rellena después.

Entre la activación y la finalización del último segmento, los documentos están construidos solo en parte y el índice nuevo aún no existe. Durante esa ventana, la búsqueda se resuelve por completo en el idioma predeterminado, con su índice, su predicado y su configuración. Cuando termina la reconstrucción, el índice nuevo pasa a estar disponible y el idioma efectivo puede cambiar como una sola unidad.

La desactivación invierte esos pasos: se eliminan los documentos y las filas de diccionario del idioma, se elimina el índice y los usuarios que lo habían seleccionado pasan al predeterminado del sistema. Los recursos en sí no cambian, porque el estado específico de cada idioma vive en los documentos de búsqueda y en los índices.

Lo que le diríamos a cualquiera que construya esto

  • Decide si traduces la consulta o el documento, y acepta toda la factura. Traducir documentos cuesta almacenamiento lineal y una reconstrucción por cada cambio de traducción. A cambio, nunca tienes que transformar una consulta y el índice se construye con términos que una persona aprobó.
  • Una alternativa es un conjunto, no un valor. Si aplicar la alternativa a un valor derivado invalida otro, no apliques la alternativa al valor derivado. Resuelve aquello de lo que todos se derivan, una vez, arriba, y recalcula todo lo que hay debajo.
  • Prefiere la coherencia a la corrección en las configuraciones de texto. Un stemmer equivocado en ambos lados se degrada con suavidad hacia la coincidencia exacta. Un stemmer correcto en un solo lado es una pérdida silenciosa, parcial e invisible de la mitad de tu corpus.
  • Los términos que exponen una discrepancia de stemming nunca son los que usas en las comprobaciones puntuales. La mitad de nuestro vocabulario más frecuente falló y la otra mitad se comportó a la perfección, divididos justo según si el stemming cambia la palabra. Prueba con un plural flexionado, no con un sustantivo.
  • Fusiona posiciones entre corpus, nunca puntuaciones. Las puntuaciones BM25 de dos índices se calculan sobre vocabularios distintos con normalizaciones de longitud distintas. Son unidades incomparables que casualmente tienen el mismo tipo de dato.
  • Una rama con tope más otra rama con tope son dos topes. Toda unión de conjuntos de candidatos limitados de forma independiente tiene que volver a recortarse, o todo lo que viene después trabaja con un presupuesto para el que no se diseñó.
  • Fija cada decisión de idioma una vez por petición. La búsqueda toca el idioma en cinco o seis etapas. Si cada una lo vuelve a leer, un cambio de ajustes a mitad de petición permite que discrepen, y habrás reconstruido el mismo error a partir de dos momentos distintos.
  • Sabe qué idiomas tu base de datos no puede indexar de verdad, y dilo. «Stemmer no disponible» y «el analizador no encuentra los límites de las palabras» son problemas de órdenes distintos, y uno de ellos no lo rescatan los trigramas, los vectores ni el optimismo.

Dónde vive esto en el producto

Todo lo anterior se ejecuta en cada búsqueda de freedam, en cada idioma activo: la galería, la API REST, el SDK de TypeScript y cualquier agente de IA conectado por MCP. Qué idiomas están activos y cuál es el predeterminado son ajustes que cambia un administrador; los índices, las reconstrucciones y las alternativas se derivan de esa única decisión. La vista del mismo tema a nivel de producto está en la página de búsqueda.

A continuación en esta serie: cómo medimos si algo de esto funciona de verdad. Un panel de sesenta casos de relevancia, lo que pasó la primera vez que lo ejecutamos contra un corpus real de cliente y por qué el diagnóstico más obvio se equivocó cuatro veces de cada cinco.

La demo ejecuta una biblioteca de un solo idioma, así que no muestra el comportamiento de este artículo. Una evaluación multilingüe útil necesita un vocabulario controlado traducido y un corpus reconstruido en cada idioma; las preguntas relevantes son qué idiomas reciben un stemmer, cuáles usan simple y cómo se resuelve la alternativa mientras un índice no está disponible.

Para desarrolladores

Sigue leyendo