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 🚀
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.
Muy simple:
¡Puedes probar la función de búsqueda en la página de Artículos !
Al momento de escribir este artículo, existen:
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.
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 .
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:
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 .
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✨
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
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:
No está mal 😎
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 😎
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
Este artículo también se publicó en el sitio web de Daw-Chih .