Desventajas de Búsqueda en SSG#
Los generadores de sitios estáticos (SSG) como Astro han dominado la creación de documentación gracias a su velocidad, seguridad y simplicidad de despliegue. Sin embargo, históricamente han fallado en una característica crítica: la búsqueda. Soluciones léxicas basadas en índices invertidos (como Lunr, FlexSearch o Fuse.js) son rápidas, pero carecen de comprensión contextual. Si un usuario busca “autenticación de usuarios”, pero tu documentación usa el término “login con credenciales”, la búsqueda léxica fallará.
Aquí es donde entra RAG (Retrieval-Augmented Generation) y la búsqueda semántica. El problema convencional es que RAG asume una arquitectura cliente-servidor: un backend de Node.js o Python conectado a una base de datos vectorial dedicada (como Pinecone o Chroma) y llamadas costosas a APIs como la de OpenAI.
En este post, vamos a romper ese paradigma. Implementaremos un sistema RAG 100% estático para tu documentación en Astro. Usaremos LangChain.js para la orquestación, generaremos los embeddings durante el proceso de build, y utilizaremos un modelo de inferencia en el navegador (vía WebAssembly) combinado con una base de datos vectorial en memoria para realizar búsquedas semánticas sin necesidad de un servidor.
La Arquitectura del RAG Estático#
Para que un buscador semántico funcione en un entorno de alojamiento estático (como GitHub Pages o Vercel sin Serverless Functions), necesitamos trasladar la carga computacional a dos momentos específicos: el tiempo de construcción (build time) y el tiempo de ejecución en el cliente (runtime).
- Fase de Indexación (Build Time): Parsearemos nuestros archivos Markdown locales de Astro, los dividiremos en chunks digeribles usando LangChain, y generaremos embeddings usando un modelo local. El resultado será un archivo
vector-store.jsonque serviremos estáticamente. - Fase de Inferencia (Runtime): Cuando el usuario interactúe con el buscador, descargaremos el
vector-store.jsona una instancia deMemoryVectorStore(el equivalente in-memory de Chroma en LangChain.js). - Vectorización de la Consulta: Utilizaremos un Web Worker en el navegador para ejecutar un modelo de embeddings ligero vía
Transformers.js, convirtiendo la consulta de texto del usuario en un vector. - Búsqueda de Similitud: Calcularemos la similitud del coseno localmente entre el vector de la consulta y nuestro store en memoria, devolviendo los resultados más relevantes instantáneamente.
Fase 1: Procesamiento de Markdown en Node.js#
El primer paso es construir el script que se ejecutará antes de astro build. Necesitamos leer nuestra carpeta de contenido, extraer el texto útil y dividirlo usando estrategias coherentes para mantener el contexto semántico.
Instalaremos las dependencias necesarias para esta etapa en nuestro entorno de desarrollo:
npm install -D @langchain/core @langchain/community @xenova/transformers gray-matterCrearemos un script llamado scripts/generate-index.js. Aquí utilizaremos el RecursiveCharacterTextSplitter de LangChain. Este splitter es superior a las expresiones regulares porque intenta mantener juntos los párrafos, luego las oraciones y finalmente las palabras, respetando la estructura semántica del contenido humano.
import fs from 'node:fs/promises';import path from 'node:path';import matter from 'gray-matter';import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';import { pipeline } from '@xenova/transformers';
const DOCS_DIR = path.join(process.cwd(), 'src/content/docs');const OUTPUT_FILE = path.join(process.cwd(), 'public/vector-store.json');
async function processDocumentation() { console.log('Iniciando indexación de documentación...');
// 1. Instanciar el modelo de embeddings local (Node.js) // Usamos all-MiniLM-L6-v2 porque es pequeño (22MB) y excelente para búsqueda semántica. const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
const files = await getMarkdownFiles(DOCS_DIR); const rawDocuments = [];
// 2. Leer y parsear Frontmatter for (const file of files) { const content = await fs.readFile(file, 'utf-8'); const { data, content: markdownBody } = matter(content);
// Generar URL basada en la ruta del archivo Astro const relativePath = path.relative(DOCS_DIR, file); const url = `/docs/${relativePath.replace(/\.mdx?$/, '')}`;
rawDocuments.push({ pageContent: markdownBody, metadata: { title: data.title || 'Sin título', url }, }); }
// 3. Dividir en Chunks const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize: 600, chunkOverlap: 100, });
const chunks = await textSplitter.createDocuments( rawDocuments.map(doc => doc.pageContent), rawDocuments.map(doc => doc.metadata) );
console.log(`Generados ${chunks.length} chunks a partir de ${files.length} archivos.`);
// 4. Generar Embeddings y almacenar const vectorStore = [];
for (const chunk of chunks) { // Calculamos el embedding del chunk de texto const output = await extractor(chunk.pageContent, { pooling: 'mean', normalize: true }); const embedding = Array.from(output.data);
vectorStore.push({ content: chunk.pageContent, metadata: chunk.metadata, embedding: embedding }); }
// 5. Guardar como JSON estático en la carpeta public de Astro await fs.writeFile(OUTPUT_FILE, JSON.stringify(vectorStore)); console.log('Índice vectorial estático guardado en public/vector-store.json');}
// Función auxiliar para recursividad de directoriosasync function getMarkdownFiles(dir) { let results = []; const list = await fs.readdir(dir, { withFileTypes: true }); for (const file of list) { const filePath = path.join(dir, file.name); if (file.isDirectory()) { results = results.concat(await getMarkdownFiles(filePath)); } else if (file.name.endsWith('.md') || file.name.endsWith('.mdx')) { results.push(filePath); } } return results;}
processDocumentation().catch(console.error);Este script es bloqueante a propósito. En tu package.json, debes integrarlo en tu ciclo de construcción: "build": "node scripts/generate-index.js && astro build". Esto garantiza que cada vez que construyas el sitio, el índice vectorial represente el estado exacto de tus archivos Markdown.
Fase 2: Ejecución de Transformers.js en el Cliente#
Ahora entra la magia del lado del cliente. Cuando un usuario introduce un término de búsqueda, necesitamos convertir ese texto en un vector para compararlo con los vectores que acabamos de generar.
Si intentamos ejecutar el modelo de Machine Learning en el hilo principal de JavaScript, la interfaz de usuario se congelará. Para evitarlo, debemos delegar la carga del modelo y la inferencia a un Web Worker.
Crea un archivo llamado public/worker.js. Al colocarlo en public, Astro lo servirá sin procesarlo a través de Vite, lo que evita problemas complejos de bundling con WebAssembly.
import { pipeline, env } from '[https://cdn.jsdelivr.net/npm/@xenova/transformers@2.16.0](https://cdn.jsdelivr.net/npm/@xenova/transformers@2.16.0)';
// Evitar descargas innecesarias almacenando en caché de IndexedDBenv.allowLocalModels = false;env.useBrowserCache = true;
let extractor = null;
self.addEventListener('message', async (event) => { const { id, text } = event.data;
try { if (!extractor) { // Lazy load del modelo en el Worker self.postMessage({ status: 'loading' }); extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { progress_callback: (x) => self.postMessage({ status: 'progress', data: x }) }); self.postMessage({ status: 'ready' }); }
// Inferencia de la consulta const output = await extractor(text, { pooling: 'mean', normalize: true }); const embedding = Array.from(output.data);
self.postMessage({ id, embedding, status: 'complete' }); } catch (error) { self.postMessage({ id, error: error.message, status: 'error' }); }});Este Web Worker actúa como nuestro microservicio privado de embeddings ejecutándose directamente dentro de la memoria RAM del dispositivo del usuario. La primera vez que se ejecute, descargará los pesos cuantizados del modelo (aprox 22MB) y los guardará en el almacenamiento en caché del navegador. Las búsquedas subsecuentes serán instantáneas, sin latencia de red.
Fase 3: Integrando el MemoryVectorStore con Astro#
Para utilizar LangChain.js en el cliente, necesitamos envolver nuestro Web Worker en una clase que LangChain pueda entender. LangChain espera una clase que extienda Embeddings.
Crearemos nuestro componente de interfaz de usuario de búsqueda, digamos Search.jsx (si usas React/Preact dentro de Astro). Aquí definiremos nuestra integración personalizada.
import { useState, useEffect, useRef } from 'react';import { Embeddings } from '@langchain/core/embeddings';import { MemoryVectorStore } from 'langchain/vectorstores/memory';
// 1. Adaptador Personalizado para LangChain + Web Workerclass WorkerEmbeddings extends Embeddings { constructor(worker) { super({}); this.worker = worker; this.callbacks = new Map(); this.msgId = 0;
this.worker.onmessage = (e) => { const { id, embedding, status, error } = e.data; if (status === 'complete' && this.callbacks.has(id)) { this.callbacks.get(id).resolve(embedding); this.callbacks.delete(id); } else if (status === 'error' && this.callbacks.has(id)) { this.callbacks.get(id).reject(new Error(error)); this.callbacks.delete(id); } }; }
async embedDocuments(texts) { return Promise.all(texts.map(t => this.embedQuery(t))); }
async embedQuery(text) { return new Promise((resolve, reject) => { const id = ++this.msgId; this.callbacks.set(id, { resolve, reject }); this.worker.postMessage({ id, text }); }); }}
// 2. Componente React para Astroexport default function SemanticSearch() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [isReady, setIsReady] = useState(false); const vectorStoreRef = useRef(null); const workerRef = useRef(null);
useEffect(() => { async function initSystem() { // Instanciar el Web Worker estático workerRef.current = new Worker('/worker.js', { type: 'module' }); const customEmbeddings = new WorkerEmbeddings(workerRef.current);
// Descargar nuestro índice estático precalculado const response = await fetch('/vector-store.json'); const staticVectors = await response.json();
// Inicializar el motor RAG en memoria (Chroma-like para cliente) vectorStoreRef.current = new MemoryVectorStore(customEmbeddings);
// Cargar los vectores al store en memoria (no usamos addDocuments // para evitar recalcular embeddings de los textos ya procesados) vectorStoreRef.current.memoryVectors = staticVectors.map((item, id) => ({ id: id.toString(), content: item.content, metadata: item.metadata, embedding: item.embedding }));
setIsReady(true); }
initSystem();
return () => { if (workerRef.current) workerRef.current.terminate(); }; }, []);
const handleSearch = async (e) => { const searchText = e.target.value; setQuery(searchText);
if (searchText.length < 3 || !isReady) { setResults([]); return; }
// Ejecución estricta de RAG const searchResults = await vectorStoreRef.current.similaritySearch(searchText, 4); setResults(searchResults); };
return ( <div className="search-container"> <input type="text" value={query} onChange={handleSearch} placeholder={isReady ? "Buscar semánticamente..." : "Cargando motor RAG..."} disabled={!isReady} className="search-input" />
{results.length > 0 && ( <ul className="search-results"> {results.map((res, index) => ( <li key={index} className="result-item"> <a href={res.metadata.url} className="result-link"> <h4>{res.metadata.title}</h4> <p>...{res.pageContent.substring(0, 120)}...</p> </a> </li> ))} </ul> )} </div> );}Consideraciones Pragmáticas sobre Rendimiento#
Implementar un motor RAG en el cliente trae una inmensa ventaja: costos de infraestructura nulos. No necesitas mantener una base de datos Chroma o Qdrant en un contenedor Docker, ni pagar cuotas por la API de OpenAI para cada pulsación de tecla de tus usuarios. Todo ocurre localmente.
Sin embargo, como Ingeniero de Software, debes estar consciente de los trade-offs:
1. El Costo Inicial de Carga (Cold Start)#
El modelo all-MiniLM-L6-v2 cuantizado pesa alrededor de 22MB. Aunque es minúsculo para un LLM, es enorme para un asset web tradicional. Gracias a la configuración de Transformers.js, esto solo impacta en la primera visita; el modelo se almacena en el caché de la IndexedDB del navegador. Es crucial proveer feedback visual (spinners, barras de progreso) mientras el Worker inicializa este modelo para evitar frustración.
2. Tamaño del vector-store.json#
A medida que tu documentación escale, el archivo JSON que contiene los embeddings precalculados crecerá. Si tienes miles de páginas, este archivo puede volverse pesado (varios megabytes de arrays flotantes). La solución a escala de producción es utilizar un mecanismo de carga por fragmentos (chunking del índice JSON) o adoptar soluciones como HNSWLib compiladas a WebAssembly en el cliente, descargando los grafos de navegación de forma asíncrona.
3. Exhaustividad vs Memoria#
El tamaño del chunk (chunkSize: 600) y el solapamiento (chunkOverlap: 100) fueron elegidos deliberadamente. Un tamaño menor genera mayor granularidad pero aumenta exponencialmente la cantidad de vectores en tu JSON. Un tamaño mayor reduce el JSON pero diluye la relevancia semántica de la búsqueda. Este parámetro es específico del dominio y debes calibrarlo analizando empíricamente qué fragmentos devuelven la mejor precisión matemática en la métrica del coseno.
Conclusión#
Integrar LangChain.js de forma estática sobre Astro reescribe las reglas del juego para sitios de documentación. Al mover el motor de búsqueda vectorial de la capa del servidor a un Web Worker en el navegador del cliente, obtenemos capacidades semánticas de nivel empresarial sin comprometer la filosofía descentralizada y ultra-segura del JAMstack.
Has construido un sistema autónomo. Tu modelo corre en local. Tus vectores son archivos estáticos servidos por CDN. Tu buscador sobrevive sin internet tras el primer render. Esa es la esencia pura de la ingeniería moderna: máxima utilidad, mínima fricción en la infraestructura.