paint-brush
Búsqueda estática de texto completo en Next.js con filtros WebAssembly, Rust y Xorpor@dawchihliou
7,356 lecturas
7,356 lecturas

Búsqueda estática de texto completo en Next.js con filtros WebAssembly, Rust y Xor

por Daw-Chih Liou2022/04/08
Read on Terminal Reader
Read this story w/o Javascript

Demasiado Largo; Para Leer

🦀 Hay un rico conjunto de herramientas para desarrollar WebAssembly con Rust. ¡Es divertido! 🤝 WebAssembly y Next.js funcionan bastante bien juntos, pero tenga en cuenta los problemas conocidos. 🧑‍🔬 Los filtros Xor son estructuras de datos que brindan una gran eficiencia de memoria y una búsqueda rápida de existencia de valor. 🧑‍🍳 El rendimiento y el tamaño del código de WebAssembly no están garantizados. Asegúrese de medir y comparar.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail

Coin Mentioned

Mention Thumbnail
featured image - Búsqueda estática de texto completo en Next.js con filtros WebAssembly, Rust y Xor
Daw-Chih Liou HackerNoon profile picture


TL;RD

  • 🦀 Hay un rico conjunto de herramientas para desarrollar WebAssembly con Rust. ¡Es divertido!
  • 🤝 WebAssembly y Next.js funcionan bastante bien juntos, pero tenga en cuenta los problemas conocidos.
  • 🧑‍🔬 Los filtros Xor son estructuras de datos que brindan una gran eficiencia de memoria y una búsqueda rápida de existencia de valor.
  • 🧑‍🍳 El rendimiento y el tamaño del código de WebAssembly no están garantizados. Asegúrese de medir y comparar.

Siempre supe que quería una función de búsqueda de texto completo para los artículos de mi cartera para proporcionar a los visitantes un acceso rápido al contenido que les interesa. Después de migrar a Contentlayer, ya no parece tan descabellado. Entonces comencé a explorar 🚀

Inspirado en tinysearch : un motor de búsqueda de texto completo de WebAssembly

Después de investigar un poco, encontré un motor de búsqueda llamado tinysearch . Es un motor de búsqueda estático construido con Rust y WebAssembly (Wasm). El autor Matthias Endler escribió una increíble publicación de blog sobre cómo surgió tinysearch .


Me encantó la idea de construir un motor de búsqueda minimalista en el momento de la compilación y enviarlo en un código optimizado de bajo nivel a los navegadores. Así que decidí usar tinysearch como modelo y escribir mi propio motor de búsqueda para integrarlo con mi sitio estático Next.js.


Recomiendo leer el código base de tinysearch . Está muy bien escrito. La implementación de mi motor de búsqueda es una versión simplificada del mismo. La lógica central es la misma.

¿Qué aspecto tiene la función de búsqueda?

Muy simple:

  • Los usuarios escriben cualquier cosa en la entrada de búsqueda.
  • El buscador busca las palabras clave en todos los contenidos y encuentra los artículos más relevantes.
  • La interfaz de usuario muestra una lista de resultados de búsqueda clasificados.


¡Puedes probar la función de búsqueda en la página de Artículos !

Un poco de estadísticas

Al momento de escribir este artículo, existen:

  • 7 artículos (más por venir🚀)
  • 13,925 palabras
  • 682 KB de archivos de datos (generados por Contentlayer)


Para que la búsqueda de texto completo funcione para sitios estáticos que se preparan para la velocidad, el tamaño del código debe ser pequeño.

¿Cómo funciona la función de búsqueda de texto completo de WebAssembly?

La mayoría de los navegadores modernos ahora admiten WebAssembly . Pueden ejecutar código WebAssembly nativo y binario junto con JavaScript.


El concepto de la función de búsqueda es sencillo. Toma una cadena de consulta como parámetro. En la función, tokenizamos la consulta en términos de búsqueda. A continuación, asignamos una puntuación de clasificación a cada artículo según la cantidad de términos de búsqueda que contiene. Finalmente, clasificamos los artículos por relevancia. Cuanto mayor sea la puntuación, más relevante es.


El flujo se ve así:


La puntuación de los artículos es donde interviene la mayor parte de la computación. Un enfoque ingenuo sería transformar cada artículo en un conjunto hash que contiene todas las palabras únicas del artículo. Podemos calcular la puntuación simplemente contando cuántos términos de búsqueda hay en el conjunto hash.


Puede imaginar que este no es el enfoque más eficiente en memoria con un conjunto hash. Hay mejores estructuras de datos para reemplazarlo: filtros xor .

¿Qué son los filtros Xor?

Los filtros Xor son estructuras de datos relativamente nuevas que nos permiten estimar si un valor existe o no. Es rápido y eficiente en memoria, por lo que es muy adecuado para la búsqueda de texto completo.


En lugar de almacenar los valores de entrada reales como un conjunto hash, los filtros xor almacenan huellas dactilares (secuencia hash de L-bit) de los valores de entrada de una manera específica . Al buscar si existe un valor en el filtro, verifica si la huella digital del valor está presente.


Sin embargo, los filtros Xor tienen un par de compensaciones:

  • Los filtros Xor son probabilísticos y existe la posibilidad de que se produzcan falsos positivos.
  • Los filtros Xor no pueden estimar la existencia de valores parciales. Entonces, en mi caso de uso, la búsqueda de texto completo solo podrá buscar palabras completas.

¿Cómo construí los filtros Xor con Rust?

Como tenía los datos del artículo generados por Contentlayer, construí los filtros xor alimentándolos con los datos antes de construir el WebAssembly. Luego serialicé los filtros xor y los almacené en un archivo. Para usar los filtros en WebAssembly, todo lo que tenía que hacer era leer el archivo de almacenamiento y deserializar los filtros.


El flujo de generación de filtros se ve así:


xorf es una buena opción para la implementación de filtros xor porque ofrece serialización/deserialización y algunas funciones que mejoran la eficiencia de la memoria y la tasa de falsos positivos. También proporciona una estructura HashProxy muy útil para mi caso de uso para construir un filtro xor con una porción de cadenas. La construcción escrita en Rust se ve más o menos así:


 use std::collections::hash_map::DefaultHasher; use xorf::{Filter, HashProxy, Xor8}; mod utils; fn build_filter(title: String, body: String) -> HashProxy<String, DefaultHasher, Xor8> { let title_tokens: HashSet<String> = utils::tokenize(&title); let body_tokens: HashSet<String> = utils::tokenize(&body); let tokens: Vec<String> = body_tokens.union(&title_tokens).cloned().collect(); HashProxy::from(&tokens) }


Si está interesado en la implementación real, puede leer más en el repositorio .

Poniendolo todo junto

Integrando WebAssembly en Next.js

Así es como integré el script de generación de filtros xor y WebAssembly dentro de Next.js.

La estructura del archivo se ve así:


 my-portfolio ├── next.config.js ├── pages ├── scripts │ └── fulltext-search ├── components │ └── Search.tsx └── wasm └── fulltext-search


Para admitir WebAssembly, actualicé mi configuración de Webpack para cargar módulos WebAssembly como módulos asíncronos. Para que funcione para la generación de sitios estáticos, necesitaba una solución alternativa para generar el módulo WebAssembly en el directorio .next/server para que las páginas estáticas puedan prerenderizarse correctamente al ejecutar el next build .


siguiente.config.js

 webpack: function (config, { isServer }) { // it makes a WebAssembly modules async modules config.experiments = { asyncWebAssembly: true } // generate wasm module in ".next/server" for ssr & ssg if (isServer) { config.output.webassemblyModuleFilename = './../static/wasm/[modulehash].wasm' } else { config.output.webassemblyModuleFilename = 'static/wasm/[modulehash].wasm' } return config },


Eso es todo lo que hay para la integración✨

Uso de The WebAssembly en el componente React

Para compilar el módulo WebAssembly a partir del código de Rust, utilizo wasm-pack .

El archivo .wasm generado y el código de conexión para JavaScript se encuentran en wasm/fulltext-search/pkg . Todo lo que tenía que hacer era usar next/dynamic para importarlos dinámicamente. Como esto:


componentes/Search.tsx

 import React, { useState, useCallback, ChangeEvent, useEffect } from 'react' import dynamic from 'next/dynamic' type Title = string; type Url = string; type SearchResult = [Title, Url][]; const Search = dynamic({ loader: async () => { const wasm = await import('../../wasm/fulltext-search/pkg') return () => { const [term, setTerm] = useState('') const [results, setResults] = useState<SearchResult>([]) const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => { setTerm(e.target.value) }, []) useEffect(() => { const pending = wasm.search(term, 5) setResults(pending) }, [term]) return ( <div> <input value={term} onChange={onChange} placeholder="🔭 search..." /> {results.map(([title, url]) => ( <a key={url} href={url}>{title}</a> ))} </div> ) } }, }) export default Search

Optimización del tamaño del código WebAssembly

Sin ninguna optimización, el tamaño original del archivo 114.56KB era de 114,56 KB. Usé Twiggy para averiguar el tamaño del código.


 Shallow Bytes │ Shallow % │ Item ───────────────┼───────────┼───────────────────── 117314 ┊ 100.00% ┊ Σ [1670 Total Rows]


En comparación con los 628KB de archivos de datos sin procesar, era mucho más pequeño de lo que esperaba. Ya estaba feliz de enviarlo a producción, pero tenía curiosidad por ver cuánto tamaño de código podía recortar con la recomendación de optimización de The Rust And WebAssembly Working Group .


El primer experimento fue alternar LTO y probar diferentes opt-level . La siguiente configuración produce el tamaño de código .wasm más pequeño:


Cargo.toml

 [profile.release] + opt-level = 's' + lto = true
 Shallow Bytes │ Shallow % │ Item ───────────────┼───────────┼───────────────────── 111319 ┊ 100.00% ┊ Σ [1604 Total Rows]


A continuación, reemplacé el asignador predeterminado con wee_alloc .


wasm/búsqueda de texto completo/src/lib.rs

 + #[global_allocator] + static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
 Shallow Bytes │ Shallow % │ Item ───────────────┼───────────┼───────────────────── 100483 ┊ 100.00% ┊ Σ [1625 Total Rows]


Luego probé la herramienta wasm-opt en Binaryen .


 wasm-opt -Oz -o wasm/fulltext-search/pkg/fulltext_search_core_bg.wasm wasm/fulltext-search/pkg/fulltext_search_core_bg.wasm
 Shallow Bytes │ Shallow % │ Item ───────────────┼───────────┼───────────────────── 100390 ┊ 100.00% ┊ Σ [1625 Total Rows]


Eso es un 14.4% de descuento del tamaño del código original.


Al final, pude enviar un motor de búsqueda de texto completo en:

  • 98,04 KB en bruto
  • 45.92 KB comprimido con gzip


No está mal 😎

¿Es realmente ágil?

Perfilé el rendimiento con web-sys y recopilé algunos datos:

  • número de búsquedas: 208

  • mín.: 0,046 ms

  • máx.: 0,814 ms

  • media: 0.0994 ms ✨

  • desviación estándar: 0.0678


De media, se tarda menos de 0,1 ms en realizar una búsqueda de texto completo.


Es bastante ágil 😎

Pensamientos finales

Después de algunos experimentos, pude crear una búsqueda de texto completo rápida y liviana con los filtros WebAssembly, Rust y xor. Se integra bien con Next.js y la generación de sitios estáticos.

La velocidad y el tamaño vienen con algunas compensaciones, pero no tienen un gran impacto en la experiencia del usuario. Si está buscando una funcionalidad de búsqueda más completa, aquí hay algunos productos geniales disponibles:


Motores de búsqueda SaaS

Motores de búsqueda estáticos

Motores de búsqueda basados en servidor

Motores de búsqueda en el navegador

Referencias


Este artículo también se publicó en el sitio web de Daw-Chih .