NOTE: Apart from
(and even then it's questionable, I'm Scottish). These are machine translated in languages I don't read. If they're terrible please contact me.
You can see how this translation was done in this article.
Tuesday, 20 August 2024
//Less than a minute
La búsqueda de contenido es una parte crítica de cualquier sitio web de contenido pesado. Mejora la capacidad de descubrir y la experiencia del usuario. En este post voy a cubrir cómo he añadido texto completo en busca de este sitio
Hay una serie de maneras de hacer búsqueda de texto completo incluyendo
En este blog me he mudado recientemente a usar Postgres para mi base de datos. Postgres tiene una función de búsqueda de texto completo que es muy potente y (algo) fácil de usar. También es muy rápido y puede manejar consultas complejas con facilidad.
Al construir yout DbContext
puede especificar qué campos tienen activada la funcionalidad de búsqueda de texto completo.
Postgres utiliza el concepto de vectores de búsqueda para lograr una búsqueda de texto completa rápida y eficiente. Un vector de búsqueda es una estructura de datos que contiene las palabras en un documento y sus posiciones. Esencialmente precomputar el vector de búsqueda para cada fila en la base de datos permite a Postgres buscar palabras en el documento muy rápidamente. Utiliza dos tipos de datos especiales para lograr esto:
Además, ofrece una función de clasificación que le permite clasificar los resultados en función de lo bien que coincidan con la consulta de búsqueda. Esto es muy potente y le permite ordenar los resultados por relevancia. PostgreSQL asigna un ranking a los resultados basado en la relevancia. La relevancia se calcula considerando factores como la proximidad de los términos de búsqueda entre sí y la frecuencia con que aparecen en el documento. Las funciones ts_rank o ts_rank_cd se utilizan para calcular este ranking.
Puede leer más sobre las características de búsqueda de texto completo de Postgres aquí
El paquete marco de la entidad Postgres aquí proporciona un potente soporte para la búsqueda de texto completo. Le permite especificar qué campos están indexados en texto completo y cómo consultarlos.
Para ello añadimos tipos de índice específicos a nuestras Entidades tal como se definen en DbContext
:
modelBuilder.Entity<BlogPostEntity>(entity =>
{
entity.HasIndex(x => new { x.Slug, x.LanguageId });
entity.HasIndex(x => x.ContentHash).IsUnique();
entity.HasIndex(x => x.PublishedDate);
entity.HasIndex(b => new { b.Title, b.PlainTextContent})
.HasMethod("GIN")
.IsTsVectorExpressionIndex("english");
...
Aquí estamos añadiendo un índice de texto completo a la Title
y PlainTextContent
campos de nuestra BlogPostEntity
. También estamos especificando que el índice debe utilizar el GIN
tipo de índice y el english
lenguaje. Esto es importante ya que le dice a Postgres cómo indexar los datos y qué lenguaje usar para detener y detener las palabras.
Esto es obviamente un problema para nuestro blog ya que tenemos varios idiomas. Desafortunadamente por ahora sólo estoy usando el english
lenguaje para todos los puestos. Esto es algo que tendré que abordar en el futuro, pero por ahora funciona lo suficientemente bien.
También añadimos un índice a nuestro Category
entidad:
modelBuilder.Entity<CategoryEntity>(entity =>
{
entity.HasIndex(b => b.Name).HasMethod("GIN").IsTsVectorExpressionIndex("english");;
...
Al hacer esto Postgres genera un vector de búsqueda para cada fila en la base de datos. Este vector contiene las palabras en el Title
y PlainTextContent
campos. Entonces podemos utilizar este vector para buscar palabras en el documento.
Esto se traduce a una función to_tsvector en SQL que genera el vector de búsqueda para la fila. Entonces podemos utilizar la función ts_rank para clasificar los resultados basados en la relevancia.
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
Aplique esto como migración a nuestra base de datos y estamos listos para empezar a buscar.
Para la búsqueda que utilizamos usaremos el EF.Functions.ToTsVector
y EF.Functions.WebSearchToTsQuery
funciones para crear un vector de búsqueda y consulta. A continuación, podemos utilizar el Matches
función para buscar la consulta en el vector de búsqueda.
var posts = await context.BlogPosts
.Include(x => x.Categories)
.Include(x => x.LanguageEntity)
.Where(x =>
EF.Functions.ToTsVector("english", x.Title + " " + x.PlainTextContent)
.Matches(EF.Functions.WebSearchToTsQuery("english", query)) // Search in title and content
&& x.Categories.Any(c =>
EF.Functions.ToTsVector("english", c.Name)
.Matches(EF.Functions.WebSearchToTsQuery("english", query))) // Search in categories
&& x.LanguageEntity.Name == "en") // Filter by language
.OrderByDescending(x =>
EF.Functions.ToTsVector("english", x.Title + " " + x.PlainTextContent)
.Rank(EF.Functions.WebSearchToTsQuery("english", query))) // Rank by relevance
.Select(x => new { x.Title, x.Slug })
.ToListAsync();
La función EF.Functions.WebSearchToTsQuery genera la consulta para la fila basada en la sintaxis común del motor de búsqueda web.
SELECT websearch_to_tsquery('english', '"sad cat" or "fat rat"');
websearch_to_tsquery
-----------------------------------
'sad' <-> 'cat' | 'fat' <-> 'rat'
En este ejemplo se puede ver que esto genera una consulta que busca las palabras "sad cat" o "fat rat" en el documento. Esta es una característica poderosa que nos permite buscar consultas complejas con facilidad.
Como se indica befpre estos métodos generan el vector de búsqueda y la consulta para la fila. A continuación, utilizar el Matches
función para buscar la consulta en el vector de búsqueda. También podemos utilizar el Rank
función para clasificar los resultados por relevancia.
Como se puede ver esto no es una simple consulta, pero es muy potente y nos permite buscar palabras en el Title
, PlainTextContent
y Category
campos de nuestra BlogPostEntity
y clasificarlos por relevancia.
Para utilizar estos (en el futuro) podemos crear un endpoint WebAPI simple que toma una consulta y devuelve los resultados. Este es un controlador simple que toma una consulta y devuelve los resultados:
[ApiController]
[Route("api/[controller]")]
public class SearchApi(MostlylucidDbContext context) : ControllerBase
{
[HttpGet]
public async Task<JsonHttpResult<List<SearchResults>>> Search(string query)
{;
var posts = await context.BlogPosts
.Include(x => x.Categories)
.Include(x => x.LanguageEntity)
.Where(x =>
EF.Functions.ToTsVector("english", x.Title + " " + x.PlainTextContent)
.Matches(EF.Functions.WebSearchToTsQuery("english", query)) // Search in title and content
&& x.Categories.Any(c =>
EF.Functions.ToTsVector("english", c.Name)
.Matches(EF.Functions.WebSearchToTsQuery("english", query))) // Search in categories
&& x.LanguageEntity.Name == "en") // Filter by language
.OrderByDescending(x =>
EF.Functions.ToTsVector("english", x.Title + " " + x.PlainTextContent)
.Rank(EF.Functions.WebSearchToTsQuery("english", query))) // Rank by relevance
.Select(x => new { x.Title, x.Slug })
.ToListAsync();
var output = posts.Select(x => new SearchResults(x.Title.Trim(), x.Slug)).ToList();
return TypedResults.Json(output);
}
Un enfoque alternativo para usar estos Índices TsVector'simple' es usar una columna generada para almacenar el vector de búsqueda y luego usar esto para buscar. Este es un enfoque más complejo, pero permite un mejor rendimiento.
Aquí modificamos nuestro BlogPostEntity
para añadir un tipo especial de columna:
[DatabaseGenerated(DatabaseGeneratedOption.Computed)]
public NpgsqlTsVector SearchVector { get; set; }
Esta es una columna computada que genera el vector de búsqueda para la fila. A continuación, podemos utilizar esta columna para buscar palabras en el documento.
A continuación, configuramos este índice dentro de nuestra definición de entidad (todavía para confirmar, pero esto también puede permitirnos tener varios idiomas especificando una columna de idioma para cada mensaje).
entity.Property(b => b.SearchVector)
.HasComputedColumnSql("to_tsvector('english', coalesce(\"Title\", '') || ' ' || coalesce(\"PlainTextContent\", ''))", stored: true);
Usted verá aquí que usamos HasComputedColumnSql
especificar explícitamente la función PostGreSQL para generar el vector de búsqueda. También especificamos que la columna se almacena en la base de datos. Esto es importante ya que le dice a Postgres que almacene el vector de búsqueda en la base de datos. Esto nos permite buscar palabras en el documento usando el vector de búsqueda.
En la base de datos esto generó esto para cada fila, que son los 'lexemes' en el documento y sus posiciones:
"'1992':464 '1996':468 '20':480 '200':115 '2007':426 '2009':428 '2012':88 '2015':397 '2018':370 '2020':372 '2021':288,327,329,399 '2022':196,243,245,290 '2024':156,158,198 '25':21,477,486,522 '3d':346 '6':203,256 '8':179,485 '90':120,566 'ab':282 'access':221 'accomplish':14 'achiev':118 'across':60 'adapt':579 'advanc':134 'applic':168,316,526 'apr':155,197 'architect':83,97,159 'architectur':307,337 ...
A continuación, podemos utilizar esta columna para buscar palabras en el documento. Podemos usar el Matches
función para buscar la consulta en el vector de búsqueda. También podemos utilizar el Rank
función para clasificar los resultados por relevancia.
var posts = await context.BlogPosts
.Include(x => x.Categories)
.Include(x => x.LanguageEntity)
.Where(x =>
// Search using the precomputed SearchVector
x.SearchVector.Matches(EF.Functions.ToTsQuery("english", query + ":*")) // Use precomputed SearchVector for title and content
&& x.Categories.Any(c =>
EF.Functions.ToTsVector("english", c.Name)
.Matches(EF.Functions.ToTsQuery("english", query + ":*"))) // Search in categories
&& x.LanguageEntity.Name == "en") // Filter by language
.OrderByDescending(x =>
// Rank based on the precomputed SearchVector
x.SearchVector.Rank(EF.Functions.ToTsQuery("english", query + ":*"))) // Use precomputed SearchVector for ranking
.Select(x => new { x.Title, x.Slug })
.ToListAsync();
Veo aquí que también usamos un constructor de consulta diferente. EF.Functions.ToTsQuery("english", query + ":*")
que nos permite ofrecer una funcionalidad de tipo TypeAhead (donde podemos escribir e.g. 'gato' y obtener 'gato', 'gatos', 'caterpillar' etc).
Además, nos permite simplificar la consulta principal post blog sólo para buscar la consulta en el SearchVector
columna. Esta es una característica poderosa que nos permite buscar palabras en el Title
, PlainTextContent
. Todavía usamos el índice que mostramos arriba para el CategoryEntity
.
x.Categories.Any(c =>
EF.Functions.ToTsVector("english", c.Name)
.Matches(EF.Functions.ToTsQuery("english", query + ":*")))
A continuación, utilizar el Rank
función para clasificar los resultados por relevancia en función de la consulta.
x.SearchVector.Rank(EF.Functions.ToTsQuery("english", query + ":*")))
Esto nos permite usar el punto final como sigue, donde podemos pasar en las primeras letras de una palabra y recuperar todos los mensajes que coincidan con esa palabra:
Usted puede ver el API en acción aquí buscar el /api/SearchApi
. (Nota; He activado Swagger para este sitio para que pueda ver la API en acción, pero la mayoría de las veces esto debe reservarse para `IsDevelopment()).
En el futuro añadiré una función TypeAhead al cuadro de búsqueda en el sitio que utiliza esta funcionalidad.
Puedes ver que es posible obtener potente funcionalidad de búsqueda usando Postgres y Entity Framework. Sin embargo, tiene complejidades y limitaciones que necesitamos tener en cuenta (como la cosa del lenguaje). En la siguiente parte voy a cubrir cómo haríamos esto usando OpenSearch - que es tiene un montón más de configuración, pero es más potente y escalable.