Buscador de MarkeUP: relevancia por niveles, Pagefind y grafo
El buscador de MarkeUP: la arquitectura de ficheros, el modelo de relevancia por niveles, la indexación local con Pagefind y el grafo de conocimiento del vault.
Navegando en el blogver navegación en todos los blogs
Orden de lectura: doc-readme → doc-guia → doc-arquitectura → doc-buscador → doc-operacion → doc-publicar → doc-auditoria
Revisión: 2026-09-13 · Índice: 73 páginas (solo artículos) · Tests: 76 unitarios · Auditoría: 55 comprobaciones en Chrome real
El buscador de MarkeUP es 100 % cliente: no hay servidor, ni cookies, ni trackers, ni dependencias externas en tiempo de ejecución. Combina tres señales léxicas y una cuarta semántica (opt-in) para ofrecer resultados relevantes y explicables:
┌─────────────────────────────────────────────────────────────────────┐
│ 1. RECUPERACIÓN LÉXICA · Pagefind (WASM, índice local) │
│ Solo indexa el CONTENIDO de los artículos (data-pagefind-body) │
├─────────────────────────────────────────────────────────────────────┤
│ 2. CONOCIMIENTO DEL SITIO · /api/search-graph.json │
│ Wikilinks + categorías + tags + aliases del vault (Obsidian) │
├─────────────────────────────────────────────────────────────────────┤
│ 3. RE-RANKING EN CLIENTE · src/lib/search-rank.ts (puro) │
│ Jerarquía estricta: TÍTULO → CONTENIDO → CONTIENE │
├─────────────────────────────────────────────────────────────────────┤
│ 4. POR SIGNIFICADO (opt-in) · /api/semantic.json + lib/semantic │
│ LSA sobre TF-IDF precalculada · coseno en el cliente │
└─────────────────────────────────────────────────────────────────────┘
Archivo de UI: src/pages/search.astro (ruta /search).
src/
├── pages/
│ ├── search.astro ← UI del buscador (marcado + script cliente)
│ └── api/
│ ├── search-graph.json.ts ← Endpoint del grafo (build-time, cacheado 1 h)
│ ├── semantic.json.ts ← Índice semántico (build-time, cacheado en memoria)
│ └── posts.json.ts ← Manifiesto de posts (tooltips)
├── lib/
│ ├── search-rank.ts ← 🔑 Núcleo de relevancia (lógica PURA)
│ ├── search-rank.test.ts ← 40 tests del núcleo
│ ├── search-graph.ts ← Genera el grafo de relacionadas
│ ├── search-graph.test.ts ← 6 tests del grafo
│ ├── aliases.ts ← Extracción de slugs/aliases del frontmatter
│ └── semantic/ ← 🔑 Canal semántico (LSA; puro salvo `build.ts`)
│ ├── tokenize.ts · math.ts · svd.ts ← troceo, coseno/cuantización, SVD
│ ├── model.ts ← construcción TF-IDF + SVD (PURA) + tests
│ ├── index-format.ts ← forma JSON y codec int8/base64
│ ├── query.ts · blend.ts ← consulta, coseno y fusión RRF + tests
│ └── build.ts ← cablea el vault con el modelo (Astro)
scripts/
└── js/dev-search.js ← Índice + grafo para desarrollo local
search-rank.ts no toca el DOM: son funciones puras (entrada → salida). Eso permite:
- Testearlo con Vitest sin navegador (40 tests, ~8 ms).
- Razonar sobre la relevancia sin ruido de UI.
- Reutilizarlo en el arnés de auditoría y en cualquier futuro consumidor.
Cada resultado se clasifica en un nivel, de forma determinista:
| Nivel | Etiqueta | Criterio | Ejemplo |
|---|---|---|---|
| 1 | título | El término es prefijo de una palabra del título | teos → “Teosofía” · antroposo → “Antroposofía” |
| 2 | contenido | El término aparece como palabra completa en el cuerpo o la descripción | buscar teosofía cuando el post la menciona |
| 3 | contiene | El término está dentro de otra palabra (coincidencia parcial) | anto ⊂ “Anton” · “canto” |
| −1 | (descartado) | El término no aparece → ruido del motor | Pagefind devuelve basura para xyz; se elimina |
Un nivel inferior NUNCA puede adelantar a uno superior.
Es una jerarquía estricta, no una suma de puntos. Esto elimina la clase de fallo clásica (“busco teosofía y aparece teoría primero”): aunque teoría puntúe alto en el motor, si no está en el título no puede superar a un nivel 1.
- Nivel 1 (título):
- Consultas ≥ 3 caracteres: ordena por raw de Pagefind (señal fiable), con la especificidad como desempate.
- Consultas 1–2 caracteres: el raw es ruido de frecuencia → ordena por especificidad (
titleMatchScore: palabra más corta que empieza por el término). - Los rescatados solo por título (que Pagefind no trajo) van después de los que sí venían del motor.
- Niveles 2 y 3: ordena por raw descendente, con límites (máx. 10 de contenido, 5 parciales) para que no ahoguen a los títulos.
threshold = max(1.0, maxRaw * 0.15)
- Nivel 1 siempre pasa (es lo que el usuario busca).
- Niveles 2 y 3 se filtran: descarta la cola de coincidencias débiles.
El vault define sinónimos en el frontmatter (aliases:). Se extraen en aliases.ts y se publican en el grafo:
orgón→el-orgonfilosofía-del-agni-yoga→agni-yoga
Al buscar, expandQuery() añade el título canónico del post a la consulta, de modo que un alias encuentra el artículo aunque el texto use otro nombre. Hay 190 aliases activos.
En src/pages/posts/[...slug].astro, el artículo va envuelto en:
<article
data-pagefind-body
data-pagefind-meta="date:…"
data-pagefind-meta="image:…"
data-pagefind-meta="reading:…"
data-pagefind-sort="date"
>
<PostHero post={post} /> <!-- h1 con data-pagefind-meta="title" (el `title` SEO) -->
<div class="prose max-w-none">
<TableOfContents … data-pagefind-ignore />
<Content />
</div>
</article>
Consecuencias:
- Pagefind indexa solo el contenido del artículo; ignora automáticamente
<nav>/<footer>. - Se excluyen además con
data-pagefind-ignore: TOC, backlinks, “fuente externa” y botones de compartir. - El índice pasa de 379 a 73 páginas (solo posts): más ligero (1,4 MB frente a 3,5 MB) y sin ruido de listados.
| Metadato | Dónde | Para qué |
|---|---|---|
title | <h1> de PostHero (peso 10) — desde el 2026-09-24 el H1 es el title SEO | Mejor raw de Pagefind; desempates |
date | <article> (+ data-pagefind-sort) | Orden por fecha (disponible) |
image | <article> | Miniatura de la tarjeta |
reading | <article> | Minutos de lectura |
Nota de diseño: categoría y etiquetas no se leen de Pagefind, sino del grafo (fuente de verdad). Pagefind no interpreta listas en un solo atributo, lo que produciría metadatos rotos.
npx pagefind --site dist --force-language es
--force-language es fija el stemming y la segmentación en español de forma determinista.
src/lib/search-graph.ts genera /api/search-graph.json en build time:
{
"generatedAt": "…",
"posts": [{
"slug": "teosofia-desarrollo-personal",
"title": "Influencia de la Teosofía…",
"description": "…",
"category": "Humano",
"tags": ["concepto", "teosofia", "…"],
"image": "/img/teosofia-desarrollo-personal-640.webp",
"date": "2026-07-08T…",
"readingMinutes": 6,
"related": [{ "slug": "antroposofia-steiner-euritmia", "score": 4.583 }]
}],
"aliases": { "orgon": "el-orgon" }
}
| Señal | Peso | Razón |
|---|---|---|
| Wikilink (bidireccional) | 3.0 | Relación explícita escrita por el autor en Obsidian |
| Categoría compartida (Jaccard) | 1.5 | proximidad temática |
| Tags compartidos (Jaccard) | 1.0 | matiz temático |
- Se conservan las 6 mejores relacionadas por post.
- 73/73 posts tienen relacionadas (media: 6.0).
- Ejemplo real: Teosofía → Antroposofía (4.58), Agni Yoga, La Respiración…
Un buscador vectorial denso exigiría descargar un modelo ONNX (~5–15 MB) y cientos de KB de vectores en cada visita, rompiendo la ligereza y la promesa “sin trackers”. Por eso el grafo de wikilinks aporta semántica específica de este sitio de forma determinista, offline y a coste cero. La excepción está medida y es opt-in: el canal «buscar por significado» usa LSA sobre TF-IDF (álgebra lineal clásica, sin modelo ONNX) y solo descarga su índice cuando el usuario lo activa (§ 15).
Cache-Control: public, max-age=3600, stale-while-revalidate=86400
- Se accionan desde el estado inicial (chips “Explora por categoría” / “Etiquetas frecuentes”) o desde los chips de tag de cada tarjeta.
- Muestran un chip removible en “Filtros: … ✕”.
- Funcionan sin texto: al elegir una categoría se lista su catálogo completo (ordenado alfabéticamente).
El vault tenía Biodanza (41) y biodanza (1) como categorías distintas. normalizeCategories() las agrupa por clave normalizada (minúsculas, sin acentos) mostrando la variante más frecuente y el recuento sumado. Sin esto, un filtro exacto fallaría.
📌 Caso histórico:
Biodanzaes hoy la categoríaHumano(renombrada el 2026-09-24). El ejemplo se conserva tal cual porque documenta el bug original.
Categoría y tag se comparan con norm() (minúsculas, sin acentos) → Máquina y maquina coinciden.
┌──────────────────────────────────────────────────────────┐
│ [ miniatura 16:9 ] Título en text-primary │
│ [Categoría] [título] 6 min │
│ Extracto con <mark>… │
│ #tag1 #tag2 #tag3 │
│ ▸ Posts relacionados │
└──────────────────────────────────────────────────────────┘
- Móvil (< 640px): la imagen ocupa todo el ancho con proporción 16:9 arriba del texto.
- ≥ 640px: miniatura en columna izquierda (
w-44) estirada a la altura de la tarjeta. - Badge de nivel con color propio:
título(verde),contenido(azul),contiene(ámbar), contitleexplicativo. - Posts relacionados desplegables (hasta 4) — exploración temática sin salir del buscador.
Cuando hay más de un nivel en los resultados, se muestran encabezados: En el título, En el contenido, Coincidencia parcial. Si todos son del mismo nivel, no se muestran (serían ruido).
Chips de categorías (con recuento) y etiquetas frecuentes (con # y recuento). Al pulsar uno se aplica el filtro sin necesidad de escribir.
- Sin resultados: mensaje contextual + sugerencias afines (categorías/tags que contienen el texto).
- Error de carga del índice: mensaje explícito (“El buscador no está disponible…”) en vez de página muda.
El estado se sincroniza con la URL mediante history.replaceState:
/search?q=teosofía
/search?cat=Humano
/search?q=energia&tag=filosofia
Al cargar, restoreFromUrl() rellena el input y los filtros y lanza la búsqueda. Permite compartir un resultado y recargar sin perder el estado.
| Tecla | Acción |
|---|---|
| (escribir) | Búsqueda instantánea (debounce 140 ms) + precarga (80 ms) |
↑ ↓ | Recorre resultados (envolvente) |
Enter | Abre el resultado enfocado |
Esc | Limpia la búsqueda |
Pagefind devuelve el extracto con HTML (<mark>). Nunca se debe confiar en HTML de terceros: si un post incluyera HTML crudo, se inyectaría en el DOM.
sanitizeExcerpt():
- Escapa todo
& < > " '. - Restaura únicamente
<mark>/</mark>.
Resultado seguro para innerHTML. Se aplica a ambos orígenes del extracto (Pagefind y descripción del grafo).
- Solo posts: cualquier URL que no empiece por
/posts/se descarta. - URL canónica: se reconstruye con
postUrl()→ siempre sin barra final (trailingSlash: 'never'), evitando 404. - CSP:
script-srcincluye'wasm-unsafe-eval'(necesario para el WASM de Pagefind) sin'unsafe-eval'. - Sin red externa:
connect-src 'self'.
Síntoma: en el deploy funcionaba en local pero en producción aparecía “El buscador no está disponible (no se pudo cargar el índice)”.
Causa raíz: /pagefind/* se servía con Cache-Control: max-age=86400.
pagefind.js y pagefind-entry.json cambian en cada build (el entry apunta
al hash del índice). El navegador recibía el entry.json antiguo mientras el
HTML era nuevo → pedía pagefind.<hash-viejo>.pf_meta → 404 → Pagefind
lanzaba internamente WASM Error (No pointer) y el índice quedaba vacío.
Detalle importante:
pagefind.init()no lanza cuando el.pf_metafalla; Pagefind captura el error y deja el índice vacío (0 resultados). Por eso eltry/catchpor sí solo no lo detecta.
Corrección (dos capas):
-
Headers (
public/_headers):pagefind.jsypagefind-entry.json→Cache-Control: no-cache.- Resto de
/pagefind/*(.pf_meta,.pf_index,.pf_fragment,wasm.*) →max-age=31536000, immutable, porque su nombre incluye el hash.
-
Auto-recuperación en el cliente (
loadPagefind()ensearch.astro):- Health check: tras
init(), una búsqueda de control (de la) debe devolver resultados. Si no, el índice está inservible. - Purga las cachés cuyo nombre contenga
pagefind. - Reimporta
pagefind.js?v=<timestamp>(esquiva la caché del módulo ES y la HTTP). - Reintenta una sola vez. El usuario no tiene que borrar nada.
- Health check: tras
Service Worker (public/sw.js): se versionó la caché (CACHE_VERSION = 'v2')
y se cambió la estrategia de /pagefind/*:
wasm.*→ cache-first (grande e inmutable dentro de la versión).- Resto → network-first con fallback a caché (antes stale-while-revalidate, que servía el índice obsoleto y rompía el buscador).
Síntoma: en local el buscador funcionaba, pero en el deploy aparecía
“Failed to load the Pagefind WASM: CompileError … violates the following
Content Security policy directive … ‘unsafe-eval’ is not an allowed source”,
mencionando un script-src sin 'wasm-unsafe-eval'.
Diagnóstico (verificado con curl sobre producción):
- El servidor servía el CSP correcto (
'wasm-unsafe-eval'presente) en/,/search/,/pagefind/pagefind.jsy/pagefind/wasm.es.pagefind. - El CSP del error era el antiguo → lo aplicaba el navegador desde una respuesta cacheada.
- Causa raíz: el WASM se llamaba
wasm.<lang>.pagefind, sin hash en la URL, y se servía conmax-age=31536000. Comocache.put()guarda la respuesta completa con sus cabeceras, la copia cacheada arrastraba el CSP vigente cuando se almacenó (sin'wasm-unsafe-eval') y al instanciarla el navegador aplicaba esa política obsoleta. - Agravante:
sw.jsprecacheaba y cacheaba el HTML (SHELL_CACHE), que también lleva el CSP en sus cabeceras.
Nota técnica: pagefind.js compone la URL del binario como
wasm.${language}.pagefind — ignora el campo wasm del entry, por lo que
no se puede renombrar el fichero añadiéndole el hash (el motor pediría una
ruta inexistente).
Corrección (3 capas):
_headers: regla específica/pagefind/wasm.*.pagefindconCache-Control: no-store. Nombre estable + sin caché = siempre fresco. El resto de/pagefind/*sigue con caché inmutable (sus nombres llevan hash).sw.js(CACHE_VERSION = 'v4'): el HTML ya no se cachea (no se intercepta la navegación) — el CSP viaja en el documento y cachearlo propaga políticas antiguas.installya no precachea/. Se eliminóSHELL_CACHE.search.astro: recuperación en dos puntos: siloadPagefind()falla con un error de CSP (CompileError/ “Content Security Policy”), se purgan todas las cachés y se recarga una vez (guardasearchCspReloadensessionStoragepara evitar bucles). La comprobación se centralizó enisCspWasmError()/recoverFromCsp()y se aplica también en elcatchdedoSearch(), porque el error puede aparecer al buscar, no al inicializar.
Verificación: escenario con CSP antiguo simulado → el buscador detecta el
bloqueo, purga cachés, marca la recarga y se recupera (2 resultados, sin error).
WASM servido con no-store (72 349 bytes, gzip). 76 tests · a11y 11/11 · build ✅.
Síntoma: en Chrome el buscador funcionaba; en Brave seguía saliendo “El
buscador no está disponible” con un CompileError de CSP en consola.
Diagnóstico (reproducido con Brave real vía CDP, perfil limpio + CSP antiguo simulado):
- Brave no bloquea WebAssembly: con un perfil limpio el buscador devuelve
resultados y
new WebAssembly.Module(...)funciona. Brave solo bloquea WASM si el usuario lo desactiva enbrave://settings/shields/filters. - La diferencia era que el perfil de usuario tenía el HTML con el CSP antiguo cacheado. Chrome ya había recargado ese documento; Brave conservaba la copia.
- Bug encontrado: la recuperación no se activaba en ese caso. El error
lo lanzaba
runSearch()(dentro dedoSearch()), noloadPagefind(), y elcatchdedoSearch()solo mostraba el mensaje — nunca detectaba el CSP. Resultado: el usuario veía el error sin que la auto-recuperación se ejecutara.
Aclaración técnica sobre load_wasm: la API pública init(overrideLanguage)
ya pasa { load_wasm: true } internamente, por lo que el WASM se compila en el
hilo principal y el CompileError sí se propaga. No hay que llamar a
pf.init(undefined, { load_wasm: true }): el primer parámetro es el idioma y
pasarlo undefined rompe la detección de idioma (el buscador devuelve 0
resultados siempre). La llamada correcta es await pf.init().
Corrección: isCspWasmError(e) + recoverFromCsp() centralizados y aplicados
tanto en loadPagefind() como en el catch de doSearch().
Verificación (5/5 con Brave real): CSP antiguo → detecta, purga y recarga sin bucle; CSP correcto → 2 resultados; relevancia → 3 resultados; tras corregir el deploy → 2 resultados; segunda búsqueda → 4 resultados.
Para el usuario que ya vio el error: basta con una recarga forzada
(Cmd + Shift + R en macOS, Ctrl + Shift + R en Windows/Linux) o borrar caché.
Con esta corrección, además, la página se recupera sola.
Síntoma: en Brave seguía apareciendo el error incluso borrando el storage y tras varios deploys; en Chrome no.
Uncaught (in promise) Error: Failed to load the Pagefind WASM:
CompileError: WebAssembly.instantiate(): Compiling ... violates the following
Content Security policy directive ... because 'unsafe-eval' is not an allowed
source of script in: "script-src 'self' 'unsafe-inline' https://d3js.org
https://static.cloudflareinsights.com".
at Worker.<anonymous> (pagefind.js?v=...:6:12449)
Los tres hallazgos que lo explican:
-
El CSP del error era el nuestro de un build antiguo (sin
'wasm-unsafe-eval'). No era del CDN ni de un preflight. -
Un worker NO hereda el CSP del documento. Se le aplica el de la respuesta de su propio script. Y
pagefind.jscrea el worker con:const workerUrl = `${basePath}pagefind-worker.js`; // URL FIJA, sin hash sharedWorker = new Worker(workerUrl);Versionar
pagefind.jsno servía de nada: el worker cuelga debasePath(/pagefind/), no de la URL del módulo. -
El worker es quien compila el WASM (
WebAssembly.instantiate()dentro del worker), así que es ahí donde revienta. Y_headersservíapagefind-worker.jsconmax-age=31536000, immutable.
Por qué era irrecuperable desde el cliente:
- Una entrada
immutabledel HTTP cache del navegador no se puede invalidar con JS:caches.delete()solo borra Cache Storage. Ni el SW ni ninguna purga pueden tocarla. - Un 304 (visto en el log del usuario:
sw.js:105 → 304) reutiliza las cabeceras viejas, así que ni revalidando se refrescaba el CSP. - El error escapaba como
Uncaught (in promise)desde el worker, fuera de cualquiertry/catch, así que la auto-recuperación nunca se activaba.
Corrección (3 capas):
_headers: regla propia para/pagefind/pagefind-worker.jsy/pagefind-v*/pagefind-worker.jsconno-storey la CSP explícita con'wasm-unsafe-eval'(necesaria justamente porque el worker no hereda la del documento). Es el mismo patrón ya usado parapagefind.jsy el WASM: URL fija + contenido cambiante ⇒no-store.- Directorio versionado (
scripts/stamp-pagefind-version.mjs): se duplicadist/pagefind/adist/pagefind-v<token>/. Comopagefind.jsderiva subasePathde su propia URL (deriveBasePath→import.meta.url.match(/^(.*\/)pagefind\.js.*$/)), basta con importar el módulo desde ese directorio para que el módulo, el worker y el WASM usen rutas nuevas en cada build: la caché deja de importar. (Es la única vía fiable:options()filtra por lista blanca y no aceptabasePath.) Eltokenes un hash hex de 16 chars: laversioncontiene:yencodeURIComponentlo volvía%3A, pidiendo un directorio inexistente. - Plan B sin worker (
search.astro): si el worker está envenenado, se reintenta connoWorker: true, que compila el WASM en el hilo principal (que SÍ tiene el CSP correcto del documento). Más un listener deunhandledrejection, que es lo único que puede interceptar un fallo nacido en el worker.
Notas de API de Pagefind 1.5.2 (importantes para no repetir errores):
| Detalle | Realidad |
|---|---|
init(overrideLanguage) | Solo acepta el idioma. Un 2º argumento se ignora en silencio. |
options(opts) | Filtra por lista blanca (baseUrl, indexWeight, excerptLength, mergeFilter, highlightParam, ranking, exactDiacritics, metaCacheTag). basePath y noWorker NO están. |
Antes de init() | options() guarda initial_options completo → ahí sí llega noWorker. |
| Construcción | PagefindWrapper se crea con {...initial_options, basePath: deriveBasePath(...)}. |
| Módulo cacheado | import() cachea por URL: reutilizarlo ignora noWorker. Por eso el plan B usa una URL única (&nw=). |
noWorker: true | No pide pagefind-worker.js en absoluto (verificado por red). |
Verificación (3/3 con Brave real): worker obsoleto (CSP antiguo +
immutable) → el buscador sigue funcionando (2 resultados, plan B activado);
worker correcto → 2 resultados; segunda búsqueda → 4 resultados. Además 76 tests,
a11y 11/11 y lint en verde.
| Elemento | Implementación |
|---|---|
| Input | role="combobox", aria-autocomplete="list", aria-controls, aria-expanded, aria-label |
| Resultados | role="listbox" + role="option" con aria-selected |
| Navegación por teclado | aria-activedescendant apunta al option enfocado |
| Contador | role="status" aria-live="polite" (“N resultados para «x»”) |
| Miniaturas | alt="" (decorativas) + aria-hidden en el enlace de la imagen |
| Foco | outline visible (.search-result--focused) |
- Índice: 73 páginas · 1,4 MB (frente a 3,5 MB indexando todo).
- Grafo: 61 KB, cacheado 1 h en Cloudflare.
- Carga diferida: Pagefind se importa como módulo; el grafo se pide en paralelo sin bloquear.
- Inicialización única:
bootIfNeeded()con marcadata-boundevita la doble init de Astro (astro:page-loadse dispara también en la carga inicial). - Precarga con debounce: 80 ms (
pagefind.preload). - Semántico (opt-in):
/api/semantic.json~729 KB (≈ 428 KB gzip), cuantizado a int8; solo se descarga al activar «Buscar por significado». - Sin dependencias externas en runtime.
src/lib/search-rank.test.ts (40 tests):
| Grupo | Cubre |
|---|---|
norm | minúsculas, acentos, null |
titleWords / queryTerms | tokenización |
queryLength | longitud útil |
hasWord | palabra completa vs fragmento |
hasTitlePrefix | prefijo en título (“anto” ≠ “antroposofía”) |
titleMatchScore | especificidad, bono por inicio |
matchLevel | niveles 1/2/3/−1 |
postUrl | URLs sin barra final |
rankItems | jerarquía y desempates |
sanitizeExcerpt | 8 casos de seguridad |
normalizeCategories | agrupación de duplicados |
formatSummary | singular/plural/total |
src/lib/search-graph.test.ts (6 tests): construcción del grafo, wikilinks bidireccionales, Jaccard, aliases, límite de relacionadas.
npm test # todos
npx vitest run src/lib/search-rank.test.ts # solo el núcleo
src/lib/semantic/ (66 tests): tokenize (norm, stopwords, troceo), math (coseno, normalización, cuantización int8 ↔ base64), svd (factorización truncada determinista), model (TF-IDF + vocabulario + LSA: límites, tablas, determinismo, calidad del espacio latente y alwaysTerms, el tema declarado —concept y title— y los aliases forzados al vocabulario), query (proyección, expansión por raíz/prefijo y tolerancia a typos con withinEditDistance1, backoff raíz↔plural con stemBackoff, null sin términos en vocabulario, umbral adaptativo, orden por coseno, round-trip del índice), eval (métricas MRR/recall sobre un índice sintético) y blend (fusión RRF y procedencia).
npx vitest run src/lib/semantic # solo el canal semántico
El subconjunto eval incluye, además, una evaluación dorada (npm run search:eval) que corre contra dist/api/semantic.json con la misma configuración que la UI y falla si el MRR baja del suelo; se salta si no hay build, así que npm run test sigue verde en un clon limpio. Detalle: docs/search.md.
Arnés que sirve dist/ por HTTP, conduce Chrome headless vía CDP y mide en el DOM real:
- Relevancia:
antroposofía/antroposo/antrop→ Antroposofía 1ª;teosofía/teos/teo→ Teosofía 1ª;biodanza,pitágoras,docker. - Sin falsos positivos:
xyz,zzzz→ 0 resultados. - Seguridad: extractos sin HTML inesperado; resaltado intacto.
- Filtros: categoría y tag activan/filtran/quitan.
- Deep-links:
?q=,?cat=restauran. - Layout multi-viewport (medido en px):
| Viewport | Imagen | Resultado |
|---|---|---|
| 320 px | ratio 1.78 (254×143) | ancho completo, encima del texto |
| 375 px | ratio 1.78 (309×174) | idem |
| 414 px | ratio 1.78 (348×196) | idem |
| 768 px | 176 px | columna izquierda |
| 1280 px | 176 px | columna izquierda |
- Otros: imagen cargada (
naturalWidth > 0), sin overflow horizontal, teclado (↑↓ envolvente, Esc),aria-activedescendant, encabezados de grupo, 0 excepciones, 0 errores de consola.
npm test # suite completa (léxico + semántico)
npm run test:a11y # 11/11 páginas
npm run validate:slugs
npm run validate:share
npm run build # incluye pagefind --force-language es
npm run dev:search
Genera el índice de Pagefind y copia el grafo a public/ para que /pagefind/ y /api/search-graph.json estén disponibles en el servidor de desarrollo.
npm run images → variantes WebP (1920/1280/640)
npm run og → tarjetas Open Graph
npm run build:astro → astro build (genera dist/ + /api/search-graph.json + /api/semantic.json)
npm run build:search → pagefind --site dist --force-language es
En src/lib/search-graph.ts:
const W_WIKILINK = 3.0; // wikilinks (relación explícita)
const W_CATEGORY = 1.5; // categoría compartida
const W_TAG = 1.0; // tags compartidos
const MAX_RELATED = 6; // nº de relacionadas por post
En src/pages/search.astro, runSearch():
- Umbral del gate:
Math.max(1.0, maxRaw * 0.15). - Límites por nivel:
.slice(0, 10)(contenido),.slice(0, 5)(parcial). - Corte de consulta corta:
queryLength(query) < 3. - Lógica de niveles y orden: en
src/lib/search-rank.ts(matchLevel,rankItems,titleMatchScore).
⚠️ Cualquier cambio en
search-rank.tsdebe ir acompañado de sus tests:npx vitest run src/lib/search-rank.test.ts.
El índice se regenera solo dentro de astro build (escribe dist/api/semantic.json); no hay comando aparte ni llamada a red. Para cambiar su tamaño o su calidad, toca los valores por defecto de BuildOptions en src/lib/semantic/model.ts (dim 160, vocabCap 3000, minDf 2) y los FIELD_WEIGHTS de src/lib/semantic/build.ts (incluyen los aliases, que aportan un puente de vocabulario barato). Los términos del tema declarado (concept + title) y los aliases entran siempre al vocabulario (alwaysTerms), aunque una palabra distintiva del título o un alias raro (poca masa) quede fuera del top vocabCap. Tras cambiar tokens, pesos o dimensiones, reconstruye y comprueba el peso de dist/api/semantic.json. Detalle: docs/search.md.
Lado de la consulta (sin reindexar). query.ts afina la recuperación en el cliente, sin tocar el índice: la expansión por raíz/prefijo (expandQueryTerms) casa plurales, género y palabras parciales contra el vocabulario, el backoff raíz↔plural (stemBackoff) añade el singular aunque el plural case exacto, la tolerancia a typos (withinEditDistance1) corrige una errata de una edición, y scoreDocs admite un umbral adaptativo (relativeThreshold) que poda la cola larga por debajo del mejor resultado. Sus parámetros (EXPANSION_WEIGHT, MIN_PREFIX_SCAN, MIN_TYPO_LEN, stemBackoff, relativeThreshold) están en src/lib/semantic/query.ts y en la llamada de search.astro.
Medir, no adivinar. La calidad se decide con un arnés dorado: npm run search:eval puntúa ~56 consultas (con su destino esperado) contra el índice real y da MRR y recall@5/10; el test eval.test.ts falla si el MRR cae del suelo. Cada cambio de la receta se mide y solo se conserva si sube la métrica (así se descartó, tras medirlo, la realimentación Rocchio; así se adoptó forzar los aliases al vocabulario —MRR 0.814 → 0.847, recall@10 al 100 %—; así, ya sobre el conjunto ampliado a ~56 consultas, se adoptó el backoff raíz↔plural stemBackoff —MRR 0.836 → 0.849, sin reindexar—; y así se forzó también el tema declarado (concept + title) —MRR 0.850 → 0.871, recall@5 92.9 % → 94.6 %—). Detalle: docs/search.md § «Evaluación».
⚠️ Cualquier cambio en
src/lib/semantic/debe ir acompañado de sus tests:npx vitest run src/lib/semantic.
| Síntoma | Causa habitual | Solución |
|---|---|---|
| “El buscador no está disponible” | Índice obsoleto en caché (hash cambiado) o CSP sin WASM | Ya se auto-recupera; verificar 'wasm-unsafe-eval' en public/_headers y que pagefind.js/entry.json vayan con no-cache |
| Un resultado enlaza a 404 | URL con barra final | postUrl() debe normalizarla (ya implementado) |
| Aparecen páginas de tags/listados | Falta data-pagefind-body | Verificar el wrapping en [...slug].astro |
| Una búsqueda no encuentra un post | El término no es prefijo de palabra del título ni palabra completa del cuerpo | Revisar si es coincidencia parcial (nivel 3) |
| Aparecen resultados irrelevantes | El término aparece legítimamente en el cuerpo | Comprobar el extracto (se resalta el término); ajustar el gate |
| Relacionadas pobres | El post no tiene wikilinks y comparte pocos tags | Añadir [[enlaces]] en el Markdown del post |
| El canal «por significado» no encuentra nada | Ningún término de la consulta toca el vocabulario ni por exacto ni por raíz/prefijo | Es esperado: queryVector devuelve null y se conserva el resultado léxico |
| El índice semántico no se descarga | Solo se pide al activar el interruptor (o al restaurar la preferencia) | Es el diseño opt-in; comprueba la pestaña Red de /api/semantic.json |
-
Jerarquía estricta en vez de score aditivo. Un score único permite que muchos factores pequeños sumen y desbanquen al resultado correcto (fallo observado:
+0.6de grafo y la frescura invertían el orden). Los niveles son predecibles y explicables. -
Grafo en vez de embeddings. Semántica específica del sitio, determinista, offline y sin peso de descarga.
-
Índice solo de artículos. Elimina el ruido de navegación/listados y reduce el índice a la mitad.
-
Núcleo de relevancia aislado y testeado. La lógica pura se verifica sin navegador, lo que hace el sistema mantenible.
-
Explicabilidad en la UI. Badges de nivel, agrupación y resaltado: el usuario ve por qué aparece cada resultado.
-
Deep-links. Convierte el buscador en algo compartible y recargable.
-
LSA en vez de embeddings. La semántica se resuelve con álgebra lineal clásica (TF-IDF + SVD truncada) precalculada en build, no con un modelo neuronal: cero dependencias, cero descarga de modelo y todo el cálculo en el navegador. Va en su propio canal opt-in para no cobrar el coste a quien solo busca por palabra (§ 15).
Además del motor léxico, /search tiene un segundo canal, opcional, que encuentra notas por idea y no por la palabra exacta. Es el I2 del roadmap (Fase 3).
Qué es. Una LSA (Latent Semantic Analysis) sobre TF-IDF precalculada en build: se vectoriza cada nota y el corpus se descompone en 160 ejes latentes con una SVD truncada aleatorizada. Dos notas que hablan de lo mismo quedan cerca aunque no compartan ninguna palabra. Nada de embeddings de un modelo ONNX: es álgebra lineal clásica, sin dependencias nuevas.
Cómo se consulta. El índice vive en /api/semantic.json (generado en build, cuantizado a int8 + base64). El coseno se calcula en el navegador; nada sale del equipo.
Tolerancia a la forma. Cada palabra de la consulta se casa con el vocabulario de forma exacta y, si no la hay, por su raíz (recorta plural/género: «meditaciones» → «meditacion»), por prefijo (palabras parciales) y, en último lugar, corrigiendo una errata de una edición («resperacion» → «respiracion», «hipnosys» → «hipnosis»), siempre a peso reducido: una coincidencia exacta manda. Además, aunque el plural case exacto («movimientos»), se añade su singular («movimiento») a peso reducido (backoff raíz↔plural): así una consulta en plural alcanza también las notas que solo usan el singular. Todo esto sube la recuperación con formas flexionadas y erratas sin indexar stemming.
Apagado por defecto (la promesa). El índice no se descarga hasta que activas el interruptor «Buscar por significado». La preferencia se guarda en localStorage y, si la activaste, el índice se precarga en segundo plano; si la descarga o la decodificación fallan, el buscador sigue en modo léxico.
En los resultados. Los aciertos que llegan solo por significado se agrupan bajo «Por significado», con un badge y su porcentaje de similitud; los que además coinciden literalmente se refuerzan. Un umbral adaptativo (se descarta lo que baja del 35 % del mejor resultado) poda la cola de parecidos flojos. Si el léxico no encuentra nada y el canal está apagado, aparece un CTA para reintentar la misma consulta «por significado» con un clic.
Grupos, no fusión. La semántica enriquece la misma búsqueda —anota similitud en los aciertos léxicos y añade su propio grupo— pero no reordena el ranking léxico: así no se sacrifica la precisión literal (decisión de diseño § 14.1). La utilidad de fusión Reciprocal Rank Fusion existe y está probada en src/lib/semantic/blend.ts, pero la UI mantiene los grupos separados.
Detalle técnico (arquitectura, receta, formato del índice y ajuste de dim/vocabCap): docs/search.md.
Buscar te devuelve posts; preguntar te devuelve una respuesta. El modo «Preguntar al vault» vive en la misma página /search pero es un canal aparte: en vez de una lista de artículos, compone un párrafo con las frases del propio vault que contestan a tu pregunta, cada una con su fuente citada ([1], [2]…). Se enlaza a la sección exacta del artículo de donde sale cada frase.
Este es un RAG extractivo: no genera texto nuevo, selecciona fragmentos que ya están escritos. Por eso no puede alucinar — lo peor que puede hacer es no encontrar nada y decírtelo. Todo ocurre en tu navegador: la pregunta no viaja a ningún servidor.
El vault se trocea en pasajes: cada sección de un artículo (lo que cuelga de un encabezado) es una unidad recuperable. Se descartan los bloques de código y de medios, que no son prosa citable.
La recuperación suma dos señales: una léxica (cuánto comparte el pasaje con tus palabras) y otra de significado (la afinidad de la LSA del artículo, la misma del canal semántico, que resuelve sinónimos, erratas y plurales). Sin la segunda, la recuperación caería a la mitad; con las dos, y con el realce por sección, a nivel de pasaje ronda el 93 % de aciertos en el top‑10 sobre el conjunto dorado del buscador (80 preguntas).
La respuesta se arma con MMR (Maximal Marginal Relevance): se eligen las frases más relevantes sin repetir cuatro veces lo mismo. Cada frase lleva la cita de su pasaje y, debajo, la lista de fuentes con el título, la sección y un recorte. La recuperación además arrastra la sección vecina de cada acierto: la idea que contesta suele empezar en el párrafo anterior o seguir en el siguiente, y así el RAG no se queda con media frase. Y cuando la pregunta toca dos temas —o quieres el detalle de fondo— el botón Más contexto rehace la respuesta con más pasajes y frases sobre la misma búsqueda (Menos contexto vuelve a la vista corta). Antes de responder, el RAG se exige una cobertura mínima de tus palabras en la evidencia, medida por lo distintivas que son (no por cuántas hay) y contando cada palabra con sus variantes de plural, raíz o errata: por debajo de ese corte, la respuesta honesta es «no lo he encontrado», con un botón para probar en el buscador léxico.
Por encima de todo eso hay una capa opcional de embeddings neuronales (el modelo e5 multilingüe corriendo en el navegador). Si el modelo está autohospedado en el sitio, la pregunta se embede localmente y afina el re‑ranking de pasajes. Es opt-in y degradable: si no está, el RAG responde igual con léxico + significado, y nada sale de tu equipo en ningún caso.
Detalle técnico (troceo, índice de pasajes, MMR, caché de embeddings y métricas): docs/search.md.
- Código de UI:
src/pages/search.astro - Núcleo de relevancia:
src/lib/search-rank.ts(+ tests) - Grafo:
src/lib/search-graph.ts(+ tests) · endpointsrc/pages/api/search-graph.json.ts - Canal semántico:
src/lib/semantic/(tokenize · math · svd · model · index-format · query · blend · build) · endpointsrc/pages/api/semantic.json.ts· manualdocs/search.md - RAG:
src/lib/semantic/(chunks · passages · rag · passage-build · embed · embed-client) · endpointsrc/pages/api/passages.json.ts - Aliases:
src/lib/aliases.ts - Indexación:
src/pages/posts/[...slug].astro,src/components/blog/PostHero.astro - Estilos:
src/styles/components.css(.search-result,.search-highlight mark,.search-group-title) - Script de desarrollo:
scripts/js/dev-search.js - CSP:
public/_headers - Documentación de Pagefind: https://pagefind.app/docs/
Para profundizar en este ámbito, The School of Life desarrolla filosofía aplicada: Alain de Botton lleva el estoicismo, el existencialismo y la ética aristotélica al amor, la ansiedad por.
doc-buscador-MarkeUP
Explora las ramas del artículo
