CreaRack-SL

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)
PiezaFicheroDetalle
Tablamigrations/0054_search_fts.sqlTabla virtual FTS5, tokenizer unicode61 remove_diacritics 2 (“búsqueda” = “busqueda”). Una fila por documento.
Consulta y upsertfunctions/_lib/search.tsbuildMatchQuery entrecomilla cada término (un ? o un bib_chunks no rompen MATCH); searchFts; upsertSearchDocs (DELETE+INSERT en db.batch); pruneSearchBefore.
Endpointfunctions/api/search/index.tsSin IA. 503 con mensaje si la migración no está aplicada.
Tool MCPbib_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).
Indexadorscripts/index-search.mjs1.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ó.
Cronscripts/cron-bib-reindex-ws.sh searchModo nuevo del reindex de OPS; log en /opt/bib-reindex/cron-bib-reindex-ws.log; el heartbeat ya vigila ese log.
Bateríascripts/bench-search.mjs120 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.
Cajitasrc/components/shell/SearchInline.tsxResultados 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_semantic dice 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 por rareTerms, 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" en wrangler.toml. Para comparar por petición, POST /api/oraculo/ask acepta "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_k 8): 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: con top_k 8 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) y test/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, excluyen LOG.md y 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.mjs a mano con MCP_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) y MCP_TOKEN=… node scripts/bench-oraculo.mjs --n 60 (Oráculo); las dos necesitan CF_ACCESS_CLIENT_ID/SECRET fuera del navegador.
  • Log del cron: tail /opt/bib-reindex/cron-bib-reindex-ws.log en 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]]