12.3. Control de la búsqueda de texto #

12.3.1. Analizar documentos
12.3.2. Analizar consultas
12.3.3. Clasificación de los resultados de búsqueda
12.3.4. Resaltar resultados

Para implementar la búsqueda en texto completo, debe haber una función para crear un tsvector a partir de un documento y un tsquery a partir de una consulta del usuario. Además, necesitamos devolver los resultados en un orden útil, por lo que necesitamos una función que compare los documentos con respecto a su relevancia con la consulta. También es importante poder mostrar los resultados de forma agradable. PostgreSQL proporciona soporte para todas estas funciones.

12.3.1. Analizar documentos #

PostgreSQL proporciona la función to_tsvector para convertir un documento al tipo de datos tsvector.

to_tsvector([ config regconfig, ] document text) returns tsvector

to_tsvector analiza un documento textual en tokens, reduce los tokens a lexemas y devuelve un tsvector que enumera los lexemas junto con sus posiciones en el documento. El documento se procesa de acuerdo con la configuración de búsqueda de texto especificada o por omisión. Aquí hay un ejemplo simple:

SELECT to_tsvector('english', 'a fat  cat sat on a mat - it ate a fat rats');
                  to_tsvector
-----------------------------------------------------
 'ate':9 'cat':3 'fat':2,11 'mat':7 'rat':12 'sat':4

En el ejemplo anterior vemos que el tsvector resultante no contiene las palabras a, on o it, la palabra rats se convirtió en rat y el signo de puntuación - fue ignorado.

La función to_tsvector llama internamente a un analizador que divide el texto del documento en tokens y asigna un tipo a cada token. Para cada token, se consulta una lista de diccionarios (Section 12.6), donde la lista puede variar según el tipo de token. El primer diccionario que reconoce el token emite uno o más lexemas normalizados para representar el token. Por ejemplo, rats se convirtió en rat porque uno de los diccionarios reconoció que la palabra rats es una forma plural de rat. Algunas palabras se reconocen como palabras vacías (Section 12.6.1), lo que hace que se ignoren, ya que ocurren con demasiada frecuencia para ser útiles en las búsquedas. En nuestro ejemplo estas son a, on e it. Si ningún diccionario de la lista reconoce el token, este también se ignora. En este ejemplo, eso le sucedió al signo de puntuación - porque en realidad no hay diccionarios asignados para su tipo de token (Space symbols), lo que significa que los tokens de espacio nunca se indexarán. Las elecciones del analizador, los diccionarios y qué tipos de tokens indexar se determinan mediante la configuración de búsqueda de texto seleccionada (Section 12.7). Es posible tener muchas configuraciones diferentes en la misma base de datos, y hay configuraciones predefinidas disponibles para varios idiomas. En nuestro ejemplo utilizamos la configuración por omisión english para el idioma inglés.

La función setweight se puede utilizar para etiquetar las entradas de un tsvector con un peso determinado, donde un peso es una de las letras A, B, C o D. Esto se suele utilizar para marcar las entradas que provienen de diferentes partes de un documento, como el título frente al cuerpo. Más tarde, esta información se puede utilizar para clasificar los resultados de la búsqueda.

Debido a que to_tsvector(NULL) devolverá NULL, se recomienda usar coalesce siempre que un campo pueda ser nulo. Aquí está el método recomendado para crear un tsvector a partir de un documento estructurado:

UPDATE tt SET ti =
    setweight(to_tsvector(coalesce(title,'')), 'A')    ||
    setweight(to_tsvector(coalesce(keyword,'')), 'B')  ||
    setweight(to_tsvector(coalesce(abstract,'')), 'C') ||
    setweight(to_tsvector(coalesce(body,'')), 'D');

Aquí hemos utilizado setweight para etiquetar el origen de cada lexema en el tsvector final, y luego hemos fusionado los valores de tsvector etiquetados utilizando el operador de concatenación de tsvector ||. (La Section 12.4.1 proporciona detalles sobre estas operaciones).

12.3.2. Analizar consultas #

PostgreSQL proporciona las funciones to_tsquery, plainto_tsquery, phraseto_tsquery y websearch_to_tsquery para convertir una consulta al tipo de datos tsquery. websearch_to_tsquery es una versión simplificada de to_tsquery con una sintaxis alternativa, similar a la utilizada por los motores de búsqueda web.

to_tsquery([ config regconfig, ] querytext text) returns tsquery

to_tsquery crea un valor tsquery a partir de querytext, el cual debe consistir en tokens simples separados por los operadores de tsquery & (AND), | (OR), ! (NOT) y <-> (FOLLOWED BY), opcionalmente agrupados mediante paréntesis. En otras palabras, la entrada para to_tsquery ya debe seguir las reglas generales para la entrada de tsquery, tal como se describe en la Section 8.11.2. La diferencia es que, mientras que la entrada básica de tsquery toma los tokens de manera literal, to_tsquery normaliza cada token en un lexema utilizando la configuración especificada o por omisión, y descarta cualquier token que sea una palabra vacía según la configuración. Por ejemplo:

SELECT to_tsquery('english', 'The & Fat & Rats');
  to_tsquery
---------------
 'fat' & 'rat'

Al igual que en la entrada básica de tsquery, se pueden adjuntar pesos a cada lexema para restringirlo a que solo coincida con lexemas de tsvector de esos pesos. Por ejemplo:

SELECT to_tsquery('english', 'Fat | Rats:AB');
    to_tsquery
------------------
 'fat' | 'rat':AB

También se puede adjuntar * a un lexema para especificar una coincidencia de prefijo:

SELECT to_tsquery('supern:*A & star:A*B');
        to_tsquery
--------------------------
 'supern':*A & 'star':*AB

Tal lexema coincidirá con cualquier palabra en un tsvector que comience con la cadena de texto dada.

to_tsquery también puede aceptar frases entre comillas simples. Esto es útil principalmente cuando la configuración incluye un diccionario de tesauro que puede activarse con tales frases. En el ejemplo siguiente, un tesauro contiene la regla supernovae stars : sn:

SELECT to_tsquery('''supernovae stars'' & !crab');
  to_tsquery
---------------
 'sn' & !'crab'

Sin comillas, to_tsquery generará un error de sintaxis para los tokens que no estén separados por un operador AND, OR o FOLLOWED BY.

plainto_tsquery([ config regconfig, ] querytext text) returns tsquery

plainto_tsquery transforma el texto sin formato querytext en un valor tsquery. El texto se analiza y normaliza de forma similar a como se hace para to_tsvector, luego se inserta el operador de tsquery & (AND) entre las palabras que sobreviven.

Ejemplo:

SELECT plainto_tsquery('english', 'The Fat Rats');
 plainto_tsquery
-----------------
 'fat' & 'rat'

Ten en cuenta que plainto_tsquery no reconocerá operadores de tsquery, etiquetas de peso ni etiquetas de coincidencia de prefijo en su entrada:

SELECT plainto_tsquery('english', 'The Fat & Rats:C');
   plainto_tsquery
---------------------
 'fat' & 'rat' & 'c'

Aquí, se descartó toda la puntuación de entrada.

phraseto_tsquery([ config regconfig, ] querytext text) returns tsquery

phraseto_tsquery se comporta de manera muy similar a plainto_tsquery, excepto que inserta el operador <-> (FOLLOWED BY) entre las palabras supervivientes en lugar del operador & (AND). Además, las palabras vacías no se descartan simplemente, sino que se tienen en cuenta insertando operadores <N> en lugar de operadores <->. Esta función es útil cuando se buscan secuencias exactas de lexemas, ya que los operadores FOLLOWED BY comprueban el orden de los lexemas y no solo la presencia de todos ellos.

Ejemplo:

SELECT phraseto_tsquery('english', 'The Fat Rats');
 phraseto_tsquery
------------------
 'fat' <-> 'rat'

Al igual que plainto_tsquery, la función phraseto_tsquery no reconocerá operadores de tsquery, etiquetas de peso ni etiquetas de coincidencia de prefijo en su entrada:

SELECT phraseto_tsquery('english', 'The Fat & Rats:C');
       phraseto_tsquery
-----------------------------
 'fat' <-> 'rat' <-> 'c'

websearch_to_tsquery([ config regconfig, ] querytext text) returns tsquery

websearch_to_tsquery crea un valor tsquery a partir de querytext utilizando una sintaxis alternativa en la que el texto simple sin formato es una consulta válida. A diferencia de plainto_tsquery y phraseto_tsquery, también reconoce ciertos operadores. Además, esta función nunca producirá errores de sintaxis, lo que permite utilizar la entrada sin procesar suministrada por el usuario para la búsqueda. Se admite la siguiente sintaxis:

  • texto sin comillas: el texto que no esté dentro de comillas se convertirá en términos separados por operadores &, como si fuera procesado por plainto_tsquery.

  • "texto entre comillas": el texto dentro de comillas se convertirá en términos separados por operadores <->, como si fuera procesado por phraseto_tsquery.

  • OR: la palabra or se convertirá en el operador |.

  • -: un guión se convertirá en el operador !.

Se ignora cualquier otra puntuación. Así que al igual que plainto_tsquery y phraseto_tsquery, la función websearch_to_tsquery no reconocerá operadores de tsquery, etiquetas de peso ni etiquetas de coincidencia de prefijo en su entrada.

Ejemplos:

SELECT websearch_to_tsquery('english', 'The fat rats');
 websearch_to_tsquery
----------------------
 'fat' & 'rat'
(1 row)

SELECT websearch_to_tsquery('english', '"supernovae stars" -crab');
       websearch_to_tsquery
----------------------------------
 'supernova' <-> 'star' & !'crab'
(1 row)

SELECT websearch_to_tsquery('english', '"sad cat" or "fat rat"');
       websearch_to_tsquery
-----------------------------------
 'sad' <-> 'cat' | 'fat' <-> 'rat'
(1 row)

SELECT websearch_to_tsquery('english', 'signal -"segmentation fault"');
         websearch_to_tsquery
---------------------------------------
 'signal' & !( 'segment' <-> 'fault' )
(1 row)

SELECT websearch_to_tsquery('english', '""" )( dummy \\ query <->');
 websearch_to_tsquery
----------------------
 'dummi' & 'queri'
(1 row)

12.3.3. Clasificación de los resultados de búsqueda #

La clasificación (ranking) intenta medir qué tan relevantes son los documentos para una consulta en particular, de modo que cuando haya muchas coincidencias se puedan mostrar primero las más relevantes. PostgreSQL proporciona dos funciones de clasificación predefinidas, que tienen en cuenta información léxica, de proximidad y estructural; es decir, consideran con qué frecuencia aparecen los términos de la consulta en el documento, qué tan cercanos están los términos entre sí en el documento y qué tan importante es la parte del documento donde ocurren. Sin embargo, el concepto de relevancia es difuso y muy específico de cada aplicación. Las diferentes aplicaciones pueden requerir información adicional para la clasificación, por ejemplo, el tiempo de modificación del documento. Las funciones de clasificación integradas son solo ejemplos. Puedes escribir tus propias funciones de clasificación y/o combinar sus resultados con factores adicionales para adaptarlos a tus necesidades específicas.

Las dos funciones de clasificación disponibles actualmente son:

ts_rank([ weights float4[], ] vector tsvector, query tsquery [, normalization integer ]) returns float4

Clasifica los vectores basándose en la frecuencia de sus lexemas coincidentes.

ts_rank_cd([ weights float4[], ] vector tsvector, query tsquery [, normalization integer ]) returns float4

Esta función calcula la clasificación de densidad de cobertura (cover density) para el vector de documento y la consulta dados, tal como se describe en "Relevance Ranking for One to Three Term Queries" de Clarke, Cormack y Tudhope en la revista "Information Processing and Management", 1999. La densidad de cobertura es similar a la clasificación ts_rank, excepto que se toma en consideración la proximidad de los lexemas coincidentes entre sí.

Esta función requiere información posicional de los lexemas para realizar su cálculo. Por lo tanto, ignora cualquier lexema despojado (stripped) en el tsvector. Si no hay lexemas con información de posición en la entrada, el resultado será cero. (Consulta la Section 12.4.1 para obtener más información sobre la función strip y la información posicional en los tsvector).

Para ambas funciones, el argumento opcional weights ofrece la posibilidad de pesar las instancias de palabras con mayor o menor fuerza según cómo estén etiquetadas. Los arrays de pesos especifican con qué fuerza pesar cada categoría de palabra, en el orden:

{D-weight, C-weight, B-weight, A-weight}

Si no se proporcionan weights, entonces se utilizan estos valores por omisión:

{0.1, 0.2, 0.4, 1.0}

Normalmente, los pesos se utilizan para marcar palabras de áreas especiales del documento, como el título o un resumen inicial, de modo que puedan tratarse con mayor o menor importancia que las palabras en el cuerpo del documento.

Dado que un documento más largo tiene una mayor probabilidad de contener un término de consulta, es razonable tener en cuenta el tamaño del documento, por ejemplo, un documento de cien palabras con cinco instancias de una palabra de búsqueda es probablemente más relevante que un documento de mil palabras con cinco instancias. Ambas funciones de clasificación toman una opción entera de normalization que especifica si la longitud de un documento debe afectar su clasificación y cómo. La opción entera controla varios comportamientos, por lo que es una máscara de bits: puedes especificar uno o más comportamientos utilizando | (por ejemplo, 2|4).

  • 0 (por omisión) ignora la longitud del documento

  • 1 divide la clasificación por (1 + el logaritmo de la longitud del documento)

  • 2 divide la clasificación por la longitud del documento

  • 4 divide la clasificación por la distancia armónica media entre extensiones (esto solo lo implementa ts_rank_cd)

  • 8 divide la clasificación por el número de palabras únicas en el documento

  • 16 divide la clasificación por (1 + el logaritmo del número de palabras únicas en el documento)

  • 32 divide la clasificación por sí misma + 1

Si se especifica más de un bit de bandera, las transformaciones se aplican en el orden indicado.

Es importante notar que las funciones de clasificación no utilizan ninguna información global, por lo que es imposible producir una normalización justa al 1% o 100% como a veces se desea. La opción de normalización 32 (rank/(rank+1)) se puede aplicar para escalar todas las clasificaciones en el rango de cero a uno, pero por supuesto esto es solo un cambio estético; no afectará el orden de los resultados de la búsqueda.

Aquí hay un ejemplo que selecciona solo las diez coincidencias con mayor clasificación:

SELECT title, ts_rank_cd(textsearch, query) AS rank
FROM apod, to_tsquery('neutrino|(dark & matter)') query
WHERE query @@ textsearch
ORDER BY rank DESC
LIMIT 10;
                     title                     |   rank
-----------------------------------------------+----------
 Neutrinos in the Sun                          |      3.1
 The Sudbury Neutrino Detector                 |      2.4
 A MACHO View of Galactic Dark Matter          |  2.01317
 Hot Gas and Dark Matter                       |  1.91171
 The Virgo Cluster: Hot Plasma and Dark Matter |  1.90953
 Rafting for Solar Neutrinos                   |      1.9
 NGC 4650A: Strange Galaxy and Dark Matter     |  1.85774
 Hot Gas and Dark Matter                       |   1.6123
 Ice Fishing for Cosmic Neutrinos              |      1.6
 Weak Lensing Distorts the Universe            | 0.818218

Este es el mismo ejemplo utilizando la clasificación normalizada:

SELECT title, ts_rank_cd(textsearch, query, 32 /* rank/(rank+1) */ ) AS rank
FROM apod, to_tsquery('neutrino|(dark & matter)') query
WHERE  query @@ textsearch
ORDER BY rank DESC
LIMIT 10;
                     title                     |        rank
-----------------------------------------------+-------------------
 Neutrinos in the Sun                          | 0.756097569485493
 The Sudbury Neutrino Detector                 | 0.705882361190954
 A MACHO View of Galactic Dark Matter          | 0.668123210574724
 Hot Gas and Dark Matter                       |  0.65655958650282
 The Virgo Cluster: Hot Plasma and Dark Matter | 0.656301290640973
 Rafting for Solar Neutrinos                   | 0.655172410958162
 NGC 4650A: Strange Galaxy and Dark Matter     | 0.650072921219637
 Hot Gas and Dark Matter                       | 0.617195790024749
 Ice Fishing for Cosmic Neutrinos              | 0.615384618911517
 Weak Lensing Distorts the Universe            | 0.450010798361481

La clasificación puede ser costosa ya que requiere consultar el tsvector de cada documento coincidente, lo que puede estar limitado por E/S y por lo tanto ser lento. Desafortunadamente, es casi imposible de evitar ya que las consultas prácticas a menudo resultan en grandes números de coincidencias.

12.3.4. Resaltar resultados #

Para presentar los resultados de la búsqueda, lo ideal es mostrar una parte de cada documento y cómo se relaciona con la consulta. Por lo general, los motores de búsqueda muestran fragmentos del documento con los términos de búsqueda marcados. PostgreSQL proporciona una función ts_headline que implementa esta funcionalidad.

ts_headline([ config regconfig, ] document text, query tsquery [, options text ]) returns text

ts_headline acepta un documento junto con una consulta y devuelve un extracto del documento en el que se resaltan los términos de la consulta. Específicamente, la función utilizará la consulta para seleccionar fragmentos de texto relevantes y luego resaltará todas las palabras que aparezcan en la consulta, incluso si esas posiciones de palabras no coinciden con las restricciones de la consulta. La configuración que se utilizará para analizar el documento se puede especificar mediante config; si se omite config, se utiliza la configuración default_text_search_config.

Si se especifica una cadena de options, debe consistir en una lista separada por comas de uno o más pares option=value. Las opciones disponibles son:

  • MaxWords, MinWords (enteros): estos números determinan los títulos más largos y más cortos a generar. Los valores por omisión son 35 y 15.

  • ShortWord (entero): las palabras de esta longitud o menor se descartarán al principio y al final de un título, a menos que sean términos de consulta. El valor por omisión de tres elimina los artículos comunes en inglés o palabras cortas sin significado.

  • HighlightAll (booleano): si es true, se utilizará todo el documento como título, ignorando los tres parámetros anteriores. El valor por omisión es false.

  • MaxFragments (entero): número máximo de fragmentos de texto a mostrar. El valor por omisión de cero selecciona un método de generación de títulos no basado en fragmentos. Un valor mayor que cero selecciona la generación de títulos basada en fragmentos (ver más abajo).

  • StartSel, StopSel (cadenas): las cadenas con las que delimitar las palabras de consulta que aparecen en el documento, para distinguirlas de otras palabras extraídas. Los valores por omisión son <b> y </b>, que pueden ser adecuados para la salida HTML (pero ver la advertencia más abajo).

  • FragmentDelimiter (cadena): cuando se muestra más de un fragmento, los fragmentos se separarán con esta cadena. El valor por omisión es ... .

Advertencia: Seguridad contra secuencias de comandos en sitios cruzados (XSS)

No se garantiza que la salida de ts_headline sea segura para su inclusión directa en páginas web. Cuando HighlightAll es false (el valor por omisión), se eliminan algunas etiquetas XML simples del documento, pero esto no garantiza la eliminación de todo el marcado HTML. Por lo tanto, esto no proporciona una defensa eficaz contra ataques como los de secuencias de comandos en sitios cruzados (XSS) cuando se trabaja con entradas no confiables. Para protegerse contra tales ataques, se debe eliminar todo el marcado HTML del documento de entrada o utilizar un desinfectador de HTML en la salida.

Estos nombres de opciones se reconocen sin distinción de mayúsculas y minúsculas. Debes poner entre comillas dobles los valores de las cadenas si contienen espacios o comas.

En la generación de títulos no basada en fragmentos, ts_headline localiza las coincidencias para la query dada y elige una sola para mostrar, prefiriendo las coincidencias que tienen más palabras de consulta dentro de la longitud permitida del título. En la generación de títulos basada en fragmentos, ts_headline localiza las coincidencias de la consulta y divide cada coincidencia en fragmentos de no más de MaxWords palabras cada uno, prefiriendo los fragmentos con más palabras de consulta y, cuando sea posible, estirando los fragmentos para incluir las palabras circundantes. El modo basado en fragmentos es, por lo tanto, más útil cuando las coincidencias de la consulta abarcan grandes secciones del documento o cuando se desea mostrar múltiples coincidencias. En cualquiera de los dos modos, si no se pueden identificar coincidencias de la consulta, se mostrará un único fragmento de las primeras MinWords palabras del documento.

Por ejemplo:

SELECT ts_headline('english',
  'The most common type of search
is to find all documents containing given query terms
and return them in order of their similarity to the
query.',
  to_tsquery('english', 'query & similarity'));
                        ts_headline
------------------------------------------------------------
 containing given <b>query</b> terms                       +
 and return them in order of their <b>similarity</b> to the+
 <b>query</b>.

SELECT ts_headline('english',
  'Search terms may occur
many times in a document,
requiring ranking of the search matches to decide which
occurrences to display in the result.',
  to_tsquery('english', 'search & term'),
  'MaxFragments=10, MaxWords=7, MinWords=3, StartSel=<<, StopSel=>>');
                        ts_headline
------------------------------------------------------------
 <<Search>> <<terms>> may occur                            +
 many times ... ranking of the <<search>> matches to decide

ts_headline utiliza el documento original, no un resumen de tsvector, por lo que puede ser lento y debe utilizarse con cuidado.