RETRIEVAL & RAG — Memora
"Las bases de datos guardan, los embeddings recuerdan, el RAG entiende. Lo nuestro es lo tercero."
Este documento es el corazón técnico de Memora. Si esto está mal hecho, el producto se siente como un buscador tonto. Si está bien hecho, se siente como tener un cronista personal con memoria fotográfica.
0. Por qué este doc existe
Memora no es una base de datos con UI. Es un sistema de recuperación de memoria personal que casualmente persiste cosas. La D1 + R2 son almacenamiento; lo que hace que el producto funcione es que cuando dices "¿qué dijo mi padre sobre el piso?" o "¿cuándo lloramos en Berlín?", el sistema encuentra.
Las apuestas técnicas:
- La búsqueda híbrida (dense + sparse + filtros estructurados) es no-negociable.
- El chunking debe ser consciente de la naturaleza de la entrada (Diario vs Crónica vs sesión vs transcripción de testigo).
- Hay que enriquecer el embedding con contexto antes de generarlo (contextual retrieval estilo Anthropic) — multiplica precision por 1.5-2x.
- Hay que re-rankear lo que devuelve Vectorize antes de mostrarlo o pasarlo al LLM.
- La dimensión temporal (fechas exactas, aproximadas, relativas) es de primera clase.
- Las personas, lugares y objetos son entidades indexadas, no solo strings.
- La capa RAG debe poder funcionar offline del LLM (dejar listos los pasos hasta el LLM, y cachear) — abarata mucho.
1. Casos de uso de retrieval (qué pedimos al sistema)
Documentamos primero los queries reales antes de elegir tecnología.
1.1 Búsqueda explícita del usuario
| Query del usuario | Intención | Estrategia |
|---|---|---|
| "piso de Lavapiés" | nominal | sparse domina (BM25) |
| "cuándo lloré por Marta" | semántica + emocional | dense domina |
| "navidades 2019" | temporal exacto | filtro estructurado + dense |
| "el verano que mejor lo pasamos" | semántica subjetiva | dense + re-rank LLM |
| "qué dijo papá del piso" | persona + tema | NER persona + dense filtrado |
| "cuando vivíamos en Berlín" | era difusa | filtro temporal + dense |
| "el día que conocí a Lola" | evento singular, posible único | dense + boost de eventos únicos |
| "todo sobre coches con Pep" | persona + tag | filtro persona + dense |
1.2 Retrieval implícito (no inicia el usuario)
| Quién pide | Qué pide | Latencia objetivo |
|---|---|---|
| Composer mientras escribes | sugerir tags/personas/period | < 500 ms |
| IA-entrevistadora en sesión Crónica | últimas entries relacionadas con lo que se está diciendo | < 800 ms |
| Generador de capítulo | top 30 entries del período + contexto adyacente | < 3 s |
| Detector de huecos (cron) | densidad por persona/era | batch nocturno |
| Asistente semanal | qué pasó la semana pasada | batch nocturno |
| Rashomon detector | ¿esta entry contradice/complementa otra? | < 1 s al guardar |
1.3 Casos especiales del producto
- Filtro estricto de intimidad: TODA query debe respetar
intimacy_levelpermitido al viewer. El bug aquí = pérdida de confianza permanente. - Multi-proyecto: por defecto query scoped a
project_idactivo; opcional global con badge. - Multi-autor (Crónica): query puede pedir "solo entries de Pep" o "todos los autores".
- Modo testigo externo (
/w/{token}): retrieval restringido a entries del capítulo invitado, jamás Vectorize search libre.
2. Stack de retrieval — overview
┌─────────────── Query usuario o sistema ───────────────┐
│ │
│ 1. Query understanding (DeepSeek light o regex) │
│ ├─ extrae temporal ("2008", "verano pasado") │
│ ├─ extrae personas ("papá", "Marta") │
│ ├─ extrae lugares ("Berlín") │
│ └─ devuelve query limpia + filtros estructurados │
│ │
│ 2. Búsqueda paralela (3 vías) │
│ ├─ DENSE: Vectorize bge-m3 (semántica) │
│ ├─ SPARSE: D1 FTS5 BM25 (lexical exacto) │
│ └─ STRUCTURED: D1 SQL (fecha, persona, lugar) │
│ │
│ 3. Fusión RRF (Reciprocal Rank Fusion) │
│ candidatos top-50 │
│ │
│ 4. Filtros de seguridad (intimacy + project + role) │
│ │
│ 5. Re-ranking │
│ ├─ rápido: BGE-reranker-v2-m3 (Workers AI) │
│ └─ premium: DeepSeek listwise re-rank top-20 │
│ │
│ 6. Diversificación (MMR opcional) │
│ evita 5 entries casi iguales │
│ │
│ 7. Devuelve top-K final (K=10 UI, K=30 LLM context) │
│ │
└────────────────────────────────────────────────────────┘
Cada paso es opcional según el caso: una sugerencia de tag en composer puede saltarse 1, 5 y 6.
3. Embeddings — modelo y dimensiones
3.1 Modelo elegido: @cf/baai/bge-m3 (Workers AI)
| Criterio | Razón |
|---|---|
| Multilingüe | 100+ idiomas, ES/EN/CA/EU/GL nativos. Crítico: Memora es bilingüe en muchos hogares. |
| Multi-función | Soporta dense, sparse y multi-vector con un único modelo. Futureproof. |
| 1024 dims | Sweet spot calidad/coste en Vectorize. |
| Free en Workers AI | $0 hasta 10k req/día. |
| Probado en codex.cadences.app con buenos resultados. | |
| Sin egress a terceros | Privacidad: el embedding se calcula dentro de Cloudflare. |
Alternativas descartadas y por qué:
text-embedding-3-small(OpenAI): no privacy-friendly, coste y dependencia externa.nomic-embed-text-v1.5: solo inglés robusto.gte-multilingual-base: peor en español según MTEB.e5-mistral-7b: caro de inferir.
3.2 Política de re-embedding (versionado crítico)
Problema clásico: al cambiar de modelo, todos tus vectores antiguos quedan inservibles porque viven en otro espacio vectorial.
Solución: cada vector lleva metadata embedding_model_version. Al lanzar nuevo modelo:
- Se duplica índice Vectorize (
memora-entries→memora-entries-v2). - Background job re-embebe entries y popla v2.
- Búsquedas se hacen contra ambos índices y se fusionan vía RRF hasta migración completa.
- Cuando v2 está al 100%, se borra v1.
Tabla embedding_job en D1 trackea progreso (entry_id, model_version, status).
3.3 Embeddings adicionales (multi-vector)
Para cada entry generamos hasta 3 vectores, cada uno especializado:
| Vector | Input | Cuándo se crea | Uso |
|---|---|---|---|
entry_main |
título + body + transcript + caption + location_text | siempre | búsqueda general |
entry_persons |
"Personas: {list of person.display_name}. Relación: {relations}. Contexto: {body resumido}" | si hay >0 personas mencionadas | queries tipo "qué pasó con X" |
entry_emotion |
DeepSeek extrae 1 frase resumen emocional → embed | opcional, lazy | queries subjetivas ("cuando fui feliz") |
Los 3 viven en el mismo índice Vectorize con metadata.vector_type distinto. Al consultar, el query también puede embebearse 3 veces (vía HyDE diferenciado) o solo el principal.
Sprint 1 = solo
entry_main. Multi-vector se añade en Sprint 2 si justifica el coste de embeddings extra.
4. Chunking — la decisión más subestimada
4.1 Tamaño objetivo
bge-m3 admite hasta 8192 tokens, pero la calidad cae en chunks largos heterogéneos. Estrategia:
| Tipo de input | Estrategia |
|---|---|
| Entry Diario corta (< 800 chars) | 1 chunk = entry entera |
| Entry Diario larga (800-3000 chars) | 1 chunk |
| Entry Crónica larga (3000-10000 chars) | partir en chunks de ~1500 chars con overlap 200 chars, en límites de párrafo |
| Sesión Crónica completa (transcript 5000-50000 chars) | NO se embebe la sesión cruda; se embeben las entries derivadas tras el split (Flow 6c) |
| Asset transcript largo (audio 60 min sin split) | split por silencios largos en chunks ~1000 chars |
| Caption foto (< 200 chars) | 1 chunk |
Regla general: entry = unidad mínima retornable al usuario. Chunks dentro de una entry comparten entry_id y se reagrupan al mostrar.
4.2 Chunking semántico (no por caracteres ciegos)
Para entries Crónica largas, en lugar de cortar por nº de chars:
- Separar por párrafos (
\n\n). - Si un párrafo > 1500 chars → separar por frases (regex idiomática multilingüe).
- Empaquetar consecutivamente hasta llenar ~1500 chars.
- Overlap 1 frase con el chunk siguiente (no 200 chars ciegos).
Implementación: helper chunkEntry(entry) en utils/chunking.js. En Sprint 1 solo simpleChunk; semántico en Sprint 2.
4.3 Contextual retrieval (Anthropic-style) — 🌟 mágico
Problema: un chunk del medio de una entry larga, fuera de contexto, dice "y después fue genial" — no se puede recuperar bien porque no se sabe a qué se refiere.
Solución (técnica que Anthropic publicó en sept 2024 y demostró +35% recall):
Antes de embebearlo, se le prepende contexto sintético generado por un LLM barato:
[Contexto: Esta entry forma parte del proyecto "Crónica con Pep", capítulo "Verano 2008 en Cádiz".
La entry trata de un viaje en coche desde Madrid. Personas mencionadas: Pep, Lola.
Era aproximada: julio 2008.]
y después fue genial, llegamos a Tarifa al amanecer y ...
El embedding del chunk-con-contexto se almacena. El usuario no ve el contexto añadido — solo se usa para enriquecer el espacio vectorial.
Coste: 1 llamada DeepSeek por chunk al crear (~200 tok input + 80 tok output ≈ $0.0001). Para 100k entries ≈ $10. Aceptable.
Implementación Sprint 2 (no Sprint 1; en Sprint 1 vamos sin contexto y medimos baseline).
4.4 Re-chunking diferido
Cuando el usuario edita una entry, sus chunks/vectores se invalidan y re-generan. Para evitar latencia: marcar entry.embedding_dirty=1 y procesar en background (waitUntil).
5. Hybrid search — dense + sparse + estructurado
5.1 Dense (Vectorize bge-m3)
- Maneja semántica, sinónimos, paráfrasis multi-idioma.
- Falla con nombres propios raros (apellidos, topónimos pequeños) y con negaciones.
5.2 Sparse (D1 FTS5 BM25)
- Tabla virtual
entry_ftscon tokenizerunicode61 remove_diacritics 2(acentos opcionales). - Indexa:
body_plain,transcript,caption,ocr_text,location_text,person_names_concat. - Imprescindible para nombres exactos: "Lavapiés" devuelve la entry literal aunque dense no la priorice.
- Coste: gratis dentro de D1.
5.3 Structured (SQL D1)
Filtros duros:
project_id IN (...)intimacy_level IN (...)(según viewer)occurred_on BETWEEN ... AND ...EXISTS (SELECT 1 FROM entry_person WHERE entry_id=e.id AND person_id IN (...))location_lat/lngcon bounding boxtags @> ARRAY[...](en SQLite simulado con join)
5.4 Fusión: RRF (Reciprocal Rank Fusion)
Algoritmo simple, sin tuning, robusto:
score(doc) = Σ 1 / (k + rank_i(doc)) para cada lista i
k = 60 (constante estándar)
Implementación: helper rrfFuse([denseResults, sparseResults, structuredResults]) devuelve top-50 candidatos ordenados.
Más sofisticado: aprender pesos por tipo de query (dense para semánticas, sparse para nominales). En V2.
5.5 Cuándo saltarse capas
- Query con número (año, fecha) → priorizar SQL estructurado, dense secundario.
- Query muy corta (< 3 palabras) → sparse + estructurado, saltar dense.
- Query larga conversacional (> 10 palabras) → dense domina, sparse refuerza.
- Sugerencia de tag (no es query usuario) → solo dense de la entry actual contra tags-vector (V2).
6. Filtros de seguridad — el bloqueo no negociable
Antes de devolver cualquier resultado, se aplica filterByVisibility(results, viewer, context):
function canSee(viewer, entry) {
// Strict order: project membership → intimacy → context
if (!hasProjectAccess(viewer, entry.project_id)) return false;
switch (entry.intimacy_level) {
case 'private':
return viewer.user_id === entry.author_user_id;
case 'intimate':
return getMemberRole(viewer, entry.project_id) === 'coauthor'
|| viewer.user_id === entry.author_user_id;
case 'circle':
return isProjectMember(viewer, entry.project_id);
case 'public':
return true;
}
}
Tests obligatorios (golden tests en CI):
- Owner ve sus
private. - Coautor NUNCA ve
privateajenas, sí veintimate. - Reader solo ve
circleypublic. - Testigo externo (
/w/{token}) solo ve entries del capítulo invitado, intimacy ≥circle, nunca Vectorize search libre.
Además, filter de Vectorize en la query (pre-filter):
env.VECTORIZE.query(values, {
topK: 50,
filter: {
project_id: { $in: accessibleProjectIds },
intimacy_level: { $in: allowedIntimacyLevels },
},
});
Reduce candidatos antes del re-rank → más rápido y más barato.
7. Re-ranking — donde se gana mucho
7.1 Por qué re-rankear
bge-m3 + RRF da 50 candidatos plausibles. Pero el orden fino entre top-10 y top-30 importa muchísimo: lo que llega al LLM como contexto define la calidad de la respuesta.
7.2 Tier 1 — re-ranker rápido (Workers AI, gratis)
Modelo: @cf/baai/bge-reranker-base (cuando esté GA en Workers AI; alternativa bge-reranker-v2-m3 self-hosted).
- Toma
(query, doc_text)pares. - Devuelve score de relevancia.
- Reordena top-50 → top-20.
- Latencia ~200 ms para 50 pares.
- Coste: gratis dentro de Workers AI free tier.
7.3 Tier 2 — re-ranker LLM (DeepSeek listwise) para casos premium
Para queries del usuario explícitas (no para sugerencias background):
- DeepSeek recibe
query + top-20 docs resumidos (200 chars c/u). - Prompt: "Ordena estos 20 documentos por relevancia para la pregunta del usuario. Devuelve solo los IDs en orden, separados por coma."
- Reordena → top-10.
- Coste: ~5k tok in + 100 tok out ≈ $0.002.
- Latencia ~1.5 s.
7.4 Cuándo aplicar cada tier
| Caso | Re-rank |
|---|---|
| Búsqueda explícita usuario | Tier 1 + Tier 2 |
| IA-entrevistadora en sesión | Tier 1 (latencia importa) |
| Generador de capítulo | Tier 1 + Tier 2 |
| Sugerencia tag/persona en composer | Ninguno |
| Asistente semanal email | Tier 1 |
8. Query understanding — extraer estructura antes de buscar
Helper parseQuery(text, locale, project_context) devuelve:
{
cleaned_text: string, // sin entidades, listo para embed
temporal: {
year?: number, // 2008
year_range?: [number, number], // [2007, 2009]
relative?: 'last_week' | 'last_month' | 'last_year' | 'recently',
season?: 'spring' | 'summer' | ...,
} | null,
persons: string[], // matching contra project.persons
locations: string[],
tags: string[],
emotion: 'positive' | 'negative' | 'neutral' | null,
intent: 'lookup' | 'recall' | 'browse' | 'subjective',
}
Implementación:
- Tier 1 (Sprint 1): regex + diccionarios (años, meses, "ayer/anteayer/semana pasada", lookup persons/locations en BD del proyecto).
- Tier 2 (Sprint 2+): DeepSeek JSON-mode si el regex falla o la query es ambigua. Prompt corto, 100 tok out.
El cleaned_text es lo que se embebe (sin "papá", "el año pasado", etc.) — porque esos términos ya van como filtros estructurados, no como semántica difusa.
9. HyDE (Hypothetical Document Embeddings) — para queries cortas
Problema: una query como "el viaje a Berlín" (4 palabras) genera un embedding pobre, lejano de las entries reales que son párrafos densos.
HyDE:
- DeepSeek genera un documento hipotético basado en la query: "En el viaje a Berlín que hicimos en 2018, recuerdo que..." (3-4 frases).
- Se embebe el documento hipotético en lugar de la query directa.
- Búsqueda dense con ese vector → mejor recall.
Cuándo aplicar:
- Solo si query < 6 palabras Y dense top-1 score < umbral 0.55.
- No para sugerencias background.
- Coste: ~300 tok DeepSeek ≈ $0.0001 por query.
Sprint 3+.
10. Capa de entidades (mini-knowledge-graph)
Memora ya tiene person, external_witness, futuro place. Pero el RAG se beneficia de hacer explícitas las relaciones.
10.1 Tabla entity unificada (V2)
Vista unificada de personas + lugares + organizaciones + objetos recurrentes ("el Citroën AX rojo de Pep").
entity
├── id, project_id, kind (person|place|org|thing), display_name, aliases[]
├── canonical_period_id # cuándo apareció en la vida del usuario
└── importance_score # contado por menciones + diversidad de eras
10.2 Relaciones (V2)
entity_co_occurrence(entity_a, entity_b, count, last_seen)
entity_period_density(entity_id, year, mention_count)
Permite queries tipo:
- "¿quiénes aparecen junto a Pep?" → top entidades con co-occurrence alto.
- "¿cuándo desapareció Marta de mi vida?" → última aparición + drop de density.
10.3 Embeddings de entidades (V2)
Cada entity recibe un embedding agregado = mean de embeddings de sus N entries top. Permite búsqueda "alguien parecido a Pep" o "otra época como la de Berlín".
11. Resúmenes jerárquicos (memoria a largo plazo)
Para proyectos Crónica de cientos de entries, el LLM no puede leer todo. Construimos resúmenes vectorizados en 3 niveles:
Level 0: entry chunks (lo que ya tenemos)
Level 1: period_summary (1 resumen por período, 500 palabras, embebido)
Level 2: project_summary (1 resumen por proyecto, 1000 palabras, embebido)
Cuando el LLM necesita contexto amplio (generador de capítulo, sesión de Crónica con tema "toda la era Madrid"), primero busca en Level 2 → identifica períodos relevantes → busca en Level 1 → desciende a Level 0 solo donde haga falta.
Generación: cron mensual + on-demand cuando se cierra un período. DeepSeek con prompt de "destila esto en {N} palabras conservando nombres propios y fechas".
Sprint 4+.
12. Live RAG en sesión Crónica — el flujo más exigente
Durante una sesión de Crónica, cada 3 segundos de silencio el sistema debe:
- Tomar últimos ~500 tokens de transcript en vivo.
- Hacer embedding de eso.
- Buscar top-5 entries relacionadas en el proyecto.
- Buscar personas/lugares mencionados pero no desarrollados.
- Generar pregunta DeepSeek streaming.
- Mostrarla en pantalla.
Latencia objetivo end-to-end: < 1.5 s.
Optimizaciones específicas:
- Cache de embedding del proyecto entero precalentado en memoria del Worker (es session-scoped, no global).
- Skip query understanding (ya estamos en contexto del proyecto).
- Skip Tier 2 re-rank.
- Vectorize query con
topK=10directamente filtrado. - DeepSeek streaming con
max_tokens=30.
13. Caching y abaratamiento
13.1 AI Gateway (Cloudflare) — caché de embeddings y completions
- Configurar AI Gateway con cache TTL 30 días para embeddings de queries (no de docs — los docs cambian).
- Caché de completions DeepSeek con TTL 1 día para respuestas determinísticas (re-rank, parseQuery).
- Hit rate esperado: 30-40% en queries repetidas del mismo usuario.
13.2 Caché de búsquedas frecuentes
Tabla query_cache(user_id, query_hash, results_json, expires_at). Para queries idénticas en < 5 min, devuelve cache. Invalidar al crear/editar entry del proyecto.
13.3 Embedding sólo si cambia contenido
Hash SHA-256 de body_plain + transcript + caption. Si no cambia entre updates, no re-embebemos.
14. Esquema D1 adicional para retrieval
-- Caché de query parsing
CREATE TABLE query_cache (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
query_hash TEXT NOT NULL,
results_json TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX idx_query_cache_user_hash ON query_cache(user_id, query_hash);
-- Trazabilidad de embeddings
CREATE TABLE embedding_job (
id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL,
vector_type TEXT NOT NULL DEFAULT 'entry_main',
model_version TEXT NOT NULL, -- 'bge-m3-2025-01'
content_hash TEXT NOT NULL,
status TEXT NOT NULL, -- 'pending' | 'done' | 'failed'
attempts INTEGER DEFAULT 0,
error TEXT,
processed_at INTEGER
);
CREATE INDEX idx_embedding_job_status ON embedding_job(status, attempts);
-- FTS5 virtual table
CREATE VIRTUAL TABLE entry_fts USING fts5(
entry_id UNINDEXED,
project_id UNINDEXED,
intimacy_level UNINDEXED,
body_plain,
transcript,
caption,
ocr_text,
location_text,
person_names, -- denormalizado, refresh on entry_person change
tokenize = 'unicode61 remove_diacritics 2'
);
-- Triggers para mantener FTS sincronizado
CREATE TRIGGER entry_after_insert AFTER INSERT ON entry BEGIN
INSERT INTO entry_fts(entry_id, project_id, intimacy_level, body_plain, transcript, caption, ocr_text, location_text, person_names)
VALUES (new.id, new.project_id, new.intimacy_level, new.body_plain, '', '', '', new.location_text, '');
END;
-- (UPDATE y DELETE análogos)
-- Vista materializada periódica
CREATE TABLE period_summary (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
period_id TEXT NOT NULL UNIQUE,
summary_md TEXT NOT NULL,
entries_count INTEGER NOT NULL,
vector_id TEXT, -- Vectorize ID
generated_at INTEGER NOT NULL,
generated_with_model TEXT NOT NULL
);
15. Estructura de archivos del retrieval layer
storefronts/memora/
├── functions/
│ └── api/
│ └── memora/
│ ├── retrieval/
│ │ ├── search.js # endpoint usuario
│ │ ├── suggest.js # endpoint composer (background)
│ │ └── live-context.js # endpoint sesión Crónica
│ └── embed/
│ ├── upsert.js # crear/actualizar vector
│ └── reindex-job.js # cron de migración modelo
└── src/
└── lib/
└── retrieval/
├── index.ts # orquestador hybridSearch()
├── parseQuery.ts # query understanding
├── chunking.ts # chunkEntry, contextual prefix
├── dense.ts # wrapper Vectorize
├── sparse.ts # wrapper FTS5
├── structured.ts # filtros SQL
├── rrf.ts # fusion
├── visibility.ts # canSee, filterByVisibility
├── rerank.ts # tier1 + tier2
├── mmr.ts # diversificación
└── cache.ts # query cache helpers
16. Endpoint principal (forma esperada)
POST /api/memora/retrieval/search
Body: {
q: "qué dijo papá del piso",
project_id?: "...", // si null, todos los proyectos accesibles
k?: 10,
include_chunks?: false, // si true, devuelve chunk-level no entry-level
filters?: {
intimacy_max?: "circle", // viewer puede pedir limitar (no ampliar)
period_id?: "...",
person_ids?: ["..."],
occurred_year?: 2008,
},
rerank?: "fast" | "premium" | "none",
hyde?: boolean,
}
Response: {
query_understanding: { ... }, // debugging, opt-in
results: [
{
entry_id: "...",
project_id: "...",
score: 0.873,
score_breakdown: { dense: 0.82, sparse: 0.91, structured: 1.0 },
snippet_md: "...", // con highlights
occurred_on: "2008-07-12",
occurred_precision: "month",
author_user: { id, display_name, avatar_url },
intimacy_level: "circle",
persons: [...],
assets_count: 2,
},
...
],
total_candidates: 47,
latency_ms: { parse: 12, dense: 184, sparse: 31, rerank: 210, total: 489 },
}
17. Métricas y evaluación (esto sin medir es religión)
17.1 Datasets internos
- Golden set v1: 50 queries con relevant entry IDs marcados a mano por mí + amigo en la beta.
- Re-evaluar al cambiar cualquier capa.
17.2 Métricas
- Recall@10, Recall@30 (¿está en los top-K?).
- MRR (Mean Reciprocal Rank) — qué tan arriba aparece el primer relevante.
- nDCG@10 — calidad del ordenamiento.
- Latencia p50/p95.
- Coste por query (tok DeepSeek + req Vectorize).
17.3 Telemetría producción
Tabla retrieval_log (sampled 10%):
retrieval_log(
ts, user_id, project_id, query_text_hashed,
candidates_dense, candidates_sparse, candidates_structured,
rerank_used, latency_ms, results_clicked_position
)
Click on result @position N = señal de relevancia → entrenar pesos RRF en V2.
18. Plan por sprint (qué entra cuándo)
| Sprint | Capacidad |
|---|---|
| S1 | Embed entry_main bge-m3, Vectorize upsert, FTS5 entry_fts, búsqueda dense+sparse simple sin re-rank, filtros project_id + intimacy estrictos, endpoint /search básico. Live RAG en sesión Crónica con embed query + Vectorize topK=5 + DeepSeek pregunta. |
| S2 | Query understanding regex, RRF fusion, re-rank Tier 1 (BGE reranker), MMR, query_cache, contextual retrieval para Crónica long entries. |
| S3 | Re-rank Tier 2 (DeepSeek listwise), HyDE para queries cortas, retrieval_log + dashboard de métricas. |
| S4 | Multi-vector (entry_persons, entry_emotion), period_summary nivel 1, embedding_job versionado. |
| S5 | project_summary nivel 2, query_understanding LLM-mode, learned RRF weights. |
| V2 | entity unificada + co-occurrence + entity embeddings, knowledge graph queries, "personas parecidas" / "épocas parecidas". |
19. Anti-patrones que vamos a EVITAR
❌ Embebear todo el contenido del usuario sin filtros de seguridad: cualquier vectorize.query sin filter project_id es un bug crítico.
❌ Confiar solo en dense: lo de "búsqueda semántica con LLM" mágica falla con nombres propios.
❌ Re-embebear en caliente al guardar: bloquea UI. Siempre waitUntil o cron.
❌ Chunks por chars ciegos: corta a mitad de frase, pierde sentido. Chunking semántico mínimo.
❌ Mezclar embeddings de múltiples usuarios en un mismo namespace sin filter pre-query: gasta cuota y arriesga leak.
❌ Cache de búsquedas sin invalidación al editar: usuario edita entry, ve resultados viejos, pierde confianza.
❌ Olvidar versionado del modelo: el día que cambiemos de bge-m3 a bge-m4, sin embedding_model_version en metadata estamos jodidos.
❌ Generar contexto sintético (Anthropic-style) con info que el viewer no tiene derecho a ver: el contexto se calcula con vista de owner, jamás se renderiza al viewer.
❌ Recuperar entries private "porque el LLM filtrará": NO. El filtro es duro en SQL/Vectorize, jamás se confía al LLM.
❌ Fetchar 200 entries para que el LLM elija: es caro y mete ruido. Re-rank Tier 1 antes de llamar al LLM.
20. Punto único de verdad: helper hybridSearch()
Toda capa que necesite retrieval (composer, search, sesión Crónica, generador de capítulo, asistente, detector de huecos) llama a UN único helper:
import { hybridSearch } from '@/lib/retrieval';
const results = await hybridSearch({
env, // Cloudflare bindings (AI, VECTORIZE, DB)
viewer, // user actual con sus permisos
query: "...", // texto crudo
scope: { project_id, multi_project: false },
options: {
k: 10,
rerank: 'fast',
hyde: false,
include_private_for_owner: true,
},
});
Cualquier feature nueva que invente su propia ruta de retrieval = code review rechazado. Una sola puerta, con tests, métricas y logs.
TL;DR
- Embedding:
@cf/baai/bge-m31024 dims en Vectorize, multi-vector en S4+, contextual retrieval en S2. - Búsqueda: híbrida dense + sparse (FTS5) + estructurado, fusión RRF, re-rank en 2 tiers.
- Seguridad: filtros pre-query + post-query, helper único
canSee(), golden tests CI. - Temporal y entidades son ciudadanos de primera clase, no hacks.
- Live RAG en sesión Crónica es el caso más exigente: < 1.5 s end-to-end.
- Resúmenes jerárquicos para proyectos grandes en S4+.
- Una sola puerta (
hybridSearch) para todo el producto. Un solo log. Una sola métrica.
Esto no se "improvisa con un wrangler que llama a Vectorize". Es la columna vertebral del producto y se diseña así desde Sprint 1, con espacio para crecer hasta knowledge graph en V2.