Búsqueda de texto completo del Workspace (FTS5 en D1 + /api/search)
Qué es
La cajita del header del Workspace busca desde el 11-09-2026 en el cuerpo entero de las 1.271 páginas de las cinco wikis y del Supercontexto, en el diario de trabajo y en los briefings STATE/NEXT/LOG. Garantía pactada con Edu el 10-09-2026 ([[decision—20260910—busqueda-texto-completo-wikis]]): si un término está en cualquier parte de una página, esa página aparece. Sustituye a la búsqueda con Fuse.js sobre un índice estático de 1,7 MB que solo llevaba los primeros 1.500 caracteres de cada página ([[feature—workspace—busqueda-global-fuse]]).
Desde la entrega 2 (11-09-2026) el Oráculo y la tool MCP bib_search_semantic también son híbridos: la misma tabla FTS5 se funde con la búsqueda por significado (sección “Entrega 2” más abajo).
Cómo funciona
scripts/index-search.mjs (cron OPS · bib-reindex-ws search · diario 01:15)
lee el repo: src/content/wiki/*.md · WORKLOG.md · public/supercontext/{STATE,briefings/NEXT,LOG}.md · páginas fijas
│ lotes de 50 · tool MCP bib_index_search · sello de generación · poda al final
▼
D1 · bib_search_fts (FTS5, migración 0054 · doc_id, title, content, product, status, kind, href, updated_at)
│ MATCH + bm25 (título ×12) · snippet() · ORDER BY bloque de wiki, relevancia · LIMIT/OFFSET
▼
GET /api/search?q=&include_drafts=0&page=1&limit=30 (auth del middleware /api/*)
│ { total, counts{producto}, results[{doc_id,title,product,status,kind,href,snippet}] }
▼
SearchInline.tsx (debounce 200 ms · grupos por wiki · Supercontexto plegado · "Cargar más" · interruptor de borradores)
| Pieza | Fichero | Detalle |
|---|---|---|
| Tabla | migrations/0054_search_fts.sql | Tabla virtual FTS5, tokenizer unicode61 remove_diacritics 2 (“búsqueda” = “busqueda”). Una fila por documento. |
| Consulta y upsert | functions/_lib/search.ts | buildMatchQuery entrecomilla cada término (un ? o un bib_chunks no rompen MATCH); searchFts; upsertSearchDocs (DELETE+INSERT en db.batch); pruneSearchBefore. |
| Endpoint | functions/api/search/index.ts | Sin IA. 503 con mensaje si la migración no está aplicada. |
| Tool MCP | bib_index_search (handlers/archivo.ts) | ≤60 docs por llamada, generation ISO común, prune_before en la última llamada. Está en el gate de escritura (un token readonly no la invoca). |
| Indexador | scripts/index-search.mjs | 1.790 documentos, 9,6 MB de texto (primera pasada real desde OPS el 11-09-2026: 19 s). --dry-run cuenta sin llamar al MCP. Salta la poda si algún lote falló. |
| Cron | scripts/cron-bib-reindex-ws.sh search | Modo nuevo del reindex de OPS; log en /opt/bib-reindex/cron-bib-reindex-ws.log; el heartbeat ya vigila ese log. |
| Batería | scripts/bench-search.mjs | 120 páginas al azar con semilla fija; cuatro consultas por página (título, frase temprana, frase tardía, identificador exacto). Línea base Fuse: 84 / 81 / 77 / 0 %. Medido el 11-09-2026 contra el sitio real (120 páginas, 464 consultas): 97 / 99 / 99 / 100 %, mediana 87 ms. |
| Cajita | src/components/shell/SearchInline.tsx | Resultados por wiki (Help, Tech, Workspace, Workspace Tech, IA Tech, diario, briefings; Supercontexto plegado con “Mostrar N fichas”), snippet con la coincidencia marcada, etiqueta de estado cuando se muestran borradores, “Cargar más”. El flujo del Oráculo se mantiene. |
Entrega 2 · Oráculo híbrido (11-09-2026)
El Oráculo buscaba solo por significado (embeddings BGE-M3 + Vectorize sobre los trozos de bib_chunks): una frase literal del cuerpo o un identificador (pgbouncer, v1.132.2, un nombre de fichero) no lo encontraban si el vector no se parecía. Ahora POST /api/oraculo/ask y bib_search_semantic llaman a searchHybrid (functions/_lib/retrieval-hybrid.ts):
pregunta ──► embedding ──► searchChunks (Vectorize, top-k) lista SEMÁNTICA (trozos)
└─► queryTerms ─► searchFts(order 'relevance') lista EXACTA (páginas, bm25, OR entre términos)
└─► ftsHitsToChunks: cada página → UN trozo
· wiki: el trozo de bib_chunks que contiene el término y más se parece a la pregunta
(si ninguno lo contiene, el primero de la página)
· diario / briefings / páginas fijas: fragmento de ~1.400 caracteres del texto completo
rrfFuse (k = 60): 1/(k+posición) por lista, se suma lo que sale en las dos ──► top-k para la síntesis
- Garantía: una página con el término entra en la lista que ve el modelo aunque el significado no la hubiera encontrado; a igual puntuación va primero lo semántico (lo que había hasta ahora).
- Floor 0,4 sin cambios: mira el mejor coseno de toda la lista fundida (los fragmentos fabricados puntúan 0 y no cuentan). Cada resultado de
bib_search_semanticdice de dónde vino:retrieval: semantic | fts | both. - Solo términos específicos: la pregunta pasa por
queryTerms(≥3 caracteres, sin “cómo”, “para”, “sirve”, “the”…) y luego porrareTerms, que consulta a D1 en cuántas páginas está cada término (una sola ida,db.batch) y se queda con los que aparecen en ≤ 5 % del corpus: “pgbouncer” o “migrate_d1” entran, “deploy” o “paso” no. Si la pregunta no tiene ninguno específico (una frase de palabras corrientes), van todas: con el título a ×3 el ruido es tolerable y una frase literal sigue encontrando su página (medido el 11-09: solo-específicos bajaba la batería de título/frase de 98/90 % a 84/65 %). El MATCH une con OR y en este modo el título pesa ×3 (no ×12): visto en PROD el 11-09, un diario de junio entraba por “paso” en el título. - Con filtros
app/source_type(que FTS no conoce) la tool sigue solo-semántica. - Marcha atrás sin código:
RETRIEVAL_HYBRID = "0"enwrangler.toml. Para comparar por petición,POST /api/oraculo/askacepta"hybrid": false(solo-semántica en esa llamada). Si FTS falla (tabla ausente, MATCH inválido) el Oráculo sigue con la lista semántica y lo deja en el log. - Coste por pregunta: tres lecturas más a D1 como máximo (trozos con el término, primer trozo de las páginas sin él, texto completo de lo que no es wiki); las listas son de 5 elementos.
- Batería
scripts/bench-oraculo.mjs(60 páginas al azar con semilla fija, vía la tool MCP,top_k8): tres palabras del título · cinco palabras seguidas del cuerpo · un identificador. Medido en PROD con la misma muestra en cada versión: solo semántica 75 / 30 / 28 % (p50 306 ms) → híbrido #172 98 / 90 / 77 (con páginas coladas por palabras comunes) → solo términos específicos #174 84 / 65 / 77 → vigente #176 (específicos si los hay, todas si no) 93 / 75 / 77 %, p50 438 ms. Deuda con plan: fundir TRES listas (semántica + exacta-específicos + exacta-todas) para recuperar los 98/90 sin el ruido; al retomar la apuesta #22 (25-09). Los fallos que quedan en “identificador” son casi todos enlaces[[slug]]que aparecen en decenas de páginas: contop_k8 no caben todas (la cajita sí las lista enteras). Comparación antes/después sobre la misma muestra: correlacional, no prueba controlada. - Tests:
test/retrieval-hybrid.test.ts(fusión, términos, fragmento) ytest/integration/retrieval-hybrid.test.ts(D1 real: orden por relevancia, elección del trozo, fragmento fabricado).
Decisiones fijadas
- Borradores y archivadas ocultos por defecto (
status IN ('active','stale')); el interruptor “Incluir borradores y archivadas” los muestra etiquetados y se recuerda por navegador. - Orden: bloques por wiki con lo escrito a mano primero; las fichas automáticas del Supercontexto al final y plegadas. Sin tope de 10: paginación de 30.
- Fuente de verdad = el fichero del repo, no
bib_chunks: los chunks saltan las secciones de menos de 30 palabras, excluyenLOG.mdy no llevan el diario. Por eso el indexador lee el repo y el índice se reconstruye entero cada noche (~1 min, sin IA). - Frescura: una página nueva o editada aparece en la búsqueda tras el reindex nocturno de OPS (o tras lanzar
node scripts/index-search.mjsa mano conMCP_TOKEN).
Cómo comprobarlo
- Test de integración contra D1 real:
pnpm exec vitest run -c vitest.workers.config.ts test/integration/search.test.ts test/integration/retrieval-hybrid.test.ts(9 + 3 casos: término más allá de los 1.500 caracteres, acentos, borradores, orden por bloque, paginación, símbolos, poda por generación). - Baterías:
MCP_TOKEN=… node scripts/bench-search.mjs --n 120(cajita) yMCP_TOKEN=… node scripts/bench-oraculo.mjs --n 60(Oráculo); las dos necesitanCF_ACCESS_CLIENT_ID/SECRETfuera del navegador. - Log del cron:
tail /opt/bib-reindex/cron-bib-reindex-ws.logen OPS, línea[OK] search …s :: Docs: N, Upserted: N, Pruned: P, Failures: 0.
Véase también
- [[decision—20260910—busqueda-texto-completo-wikis]]
- [[feature—workspace—busqueda-global-fuse]]
- [[workspace-tech—arquitectura—oraculo]]
- [[feature—workspace—oraculo-ui]]