Ir al contenido

Búsqueda Híbrida (FTS5 + RRF)

La búsqueda pura por embeddings KNN destaca encontrando entidades semánticamente similares — sinónimos, paráfrasis, incluso coincidencias cross-lingual. Pero falla cuando se buscan términos exactos como nombres propios, identificadores o jerga técnica. La representación vectorial de “FTS5” no coincidirá de forma fiable con un pasaje que contiene literalmente “FTS5” — el modelo opera en el espacio de significado, no en el espacio de tokens.

FTS5 (BM25) es la imagen especular: excelente en coincidencia literal de tokens pero ciega a relaciones semánticas. Una búsqueda de “vector database” no encontrará una entidad cuya observación diga “embedding storage” a menos que los tokens exactos coincidan.

La búsqueda híbrida combina ambas señales para obtener lo mejor de cada mundo:

MétodoFortalezaDebilidad
KNN (semántico)Sinónimos, paráfrasis, cross-lingualTérminos exactos, palabras raras, IDs
FTS5 (BM25)Términos exactos, nombres, IDs, jergaComprensión semántica, sinónimos
Híbrido (RRF)AmbasPipeline ligeramente más complejo

Al llamar search_semantic("FTS5 configuration"), el motor ejecuta un pipeline de seis pasos que se ramifica en búsquedas paralelas, las fusiona y reordena con señales límbicas:

graph TD
Q["① Codificar consulta<br/>engine.encode(text, task='query')<br/>→ float32[384]"]
Q --> KNN["② Rama semántica (KNN)<br/>sqlite-vec KNN search<br/>→ 3 × limit candidatos"]
Q --> FTS["② Rama de texto completo (FTS5)<br/>BM25 sobre name, entity_type, obs_text<br/>→ 3 × limit candidatos"]
KNN -->|"[{entity_id, distance}]"| RRF["③ Fusionar con RRF<br/>score = Σ 1/(k + rank_i)<br/>k = 60"]
FTS -->|"[{entity_id, rank}]"| RRF
RRF -->|"[{entity_id, rrf_score, dist?}]"| Limbic["④ Re-ranking Límbico<br/>salience · temporal · cooc<br/>→ rank_hybrid_candidates()"]
Limbic --> Hydrate["⑤ Hidratar entidades<br/>get_entity_by_id()<br/>+ get_observations()"]
Hydrate --> Track["⑥ Registrar señales de acceso<br/>record_access()<br/>+ record_co_occurrences()"]
Track --> Output["Output: [{name, entityType, observations,<br/>limbic_score, scoring, distance, rrf_score}]"]

① Codificar consulta — El texto de la consulta se prefija con "query: " y se codifica en un vector de 384 dimensiones por el motor de embeddings ONNX. Es el mismo pipeline de codificación usado en Búsqueda Semántica, operando en modo consulta.

② Recuperación paralela — Dos búsquedas independientes se ejecutan como ramas separadas contra la misma consulta:

  • Semántica (KNN): El vector de consulta se compara contra todos los embeddings de entidades almacenados en sqlite-vec. Para dejar margen al re-ranking, el motor recupera 3 × limit candidatos (over-retrieval). La métrica de distancia es coseno: d = 1 - cos(A, B).
  • Texto completo (FTS5): El texto de la consulta se busca en un índice BM25 que cubre nombres de entidades, tipos y contenido de observaciones. También recupera 3 × limit candidatos.

③ Fusionar con RRF — Los resultados de ambas ramas se fusionan usando Reciprocal Rank Fusion (RRF). Este paso produce un ranking unificado donde las entidades encontradas por ambos métodos reciben un impulso. Ver Reciprocal Rank Fusion más abajo.

④ Re-ranking Límbico — Los candidatos fusionados se puntúan mediante el Sistema Límbico, que aplica salience, decaimiento temporal y boosts de co-ocurrencia. En modo híbrido usa rank_hybrid_candidates() en lugar de rank_candidates().

⑤ Hidratar entidades — Los IDs de entidad top-K se resuelven en entidades completas con sus nombres, tipos y observaciones desde la base de datos SQLite.

⑥ Registrar señales de acceso — Tras construir la respuesta, el motor registra qué entidades fueron accedidas y cuáles aparecieron juntas (co-ocurrencias). Esto es best-effort y no afecta los resultados devueltos, pero alimenta el scoring límbico futuro.

El índice de texto completo es una tabla virtual SQLite FTS5:

CREATE VIRTUAL TABLE IF NOT EXISTS entity_fts
USING fts5(name, entity_type, obs_text, tokenize="unicode61");
ColumnaTipoDescripción
nameTEXTNombre de la entidad — directamente buscable
entity_typeTEXTTipo de entidad — permite consultas por tipo (“Project”, “Session”)
obs_textTEXTTodas las observaciones concatenadas con separador " | "
rowidINTEGERImplícito — corresponde a entities.id para lookups sin JOIN

Tokenizer: unicode61 — maneja correctamente caracteres acentuados (é, ñ, ü) y otro Unicode. Esto es esencial para un knowledge graph multilingüe.

La tabla FTS se mantiene a nivel de código, no mediante triggers de SQLite. El método _sync_fts(entity_id) lee el estado actual de la entidad desde la DB y ejecuta INSERT OR REPLACE en la tabla FTS:

OperaciónMétodo invocadoComportamiento
upsert_entity_sync_fts(entity_id)INSERT OR REPLACE con datos actuales
add_observations_sync_fts(entity_id)Reconstruye obs_text desde la DB
delete_observations_sync_fts(entity_id)Reconstruye obs_text desde la DB
delete_entitiesDELETE directo por rowidBorrado manual (FTS5 no soporta CASCADE)
init_db (backfill)_backfill_fts()Rellena desde entidades existentes si FTS está vacío

La fusión de rankings usa la fórmula estándar de RRF:

rrf_score(d) = Σ_{i ∈ rankings} 1 / (k + rank_i(d))

Donde:

  • rank_i(d) = posición basada en 1 del documento d en el ranking i
  • k = constante de suavizado (RRF_K = 60, valor estándar del paper original)

Por qué funciona: RRF no requiere que los scores sean comparables entre sistemas. KNN produce distancias coseno y FTS5 produce ranks BM25 — escalas diferentes, distribuciones diferentes. RRF solo se preocupa por la posición en cada ranking, lo que lo hace ideal para recuperación heterogénea.

EscenarioEfecto
Entidad en ambos rankingsRecibe score de ambos → impulsada al tope
Entidad solo en KNNRecibe score parcial según su rank KNN
Entidad solo en FTS5Recibe score parcial según su rank BM25

Dado limit = 103 × 10 = 30 candidatos por rama:

Rank KNNEntidadRank FTS5Score RRF
1Entity A31/(60+1) + 1/(60+3) = 0.0322
2Entity B1/(60+2) = 0.0161
Entity C11/(60+1) = 0.0164
5Entity D21/(60+5) + 1/(60+2) = 0.0315

Entity A aparece en ambos rankings en posiciones 1 y 3, recibiendo el score combinado más alto. Entity C aparece solo en FTS5 pero en rank 1, por lo que supera a Entity B (rank KNN 2).

def reciprocal_rank_fusion(
semantic_results: list[dict], # [{entity_id, distance}] ordenados por distancia
fts_results: list[dict], # [{entity_id, rank}] ordenados por rank BM25
k: int = RRF_K, # 60
) -> list[dict]:
# Retorna [{entity_id, rrf_score, distance | None}] ordenados por rrf_score desc

Cuando la búsqueda híbrida está activa, el Sistema Límbico usa rank_hybrid_candidates() en lugar de rank_candidates(). La diferencia clave es cómo se calcula la relevancia base:

Origen de la entidadbase_relevanceFuente
KNN + FTS (ambos)max(0, 1 - distance)Similitud coseno desde KNN
Solo KNNmax(0, 1 - distance)Similitud coseno desde KNN
Solo FTS (sin KNN)0.2 + 0.6 × norm_rrfRRF normalizado a [0.2, 0.8]

Las entidades encontradas solo por FTS5 no tienen distancia KNN (distance = None). Sin una señal de similitud vectorial, no se puede usar la fórmula coseno. En su lugar, su score RRF se normaliza min-max al rango [0.2, 0.8]:

norm_rrf = (rrf_score - rrf_min) / rrf_range
base_relevance = 0.2 + 0.6 * norm_rrf # → [0.2, 0.8]

Los límites evitan que las entidades solo-FTS dominen (techo en 0.8) o queden enterradas (piso en 0.2). Los componentes límbicos se aplican encima:

limbic_score = base_relevance × (1 + β_sal × importance) × temporal × (1 + γ × cooc_boost)

Esta es la misma fórmula compuesta usada en modo semántico puro — solo cambia base_relevance.

El pipeline elige automáticamente entre modo híbrido y semántico puro según la disponibilidad de FTS5:

graph TD
Search["search_semantic(query, limit)"]
Search --> FTSCheck{"¿FTS5 tiene resultados?"}
FTSCheck -->|Sí| Hybrid["Modo híbrido<br/>rank_hybrid_candidates()<br/>+ rrf_score en output"]
FTSCheck -->|No| Pure["Modo semántico puro<br/>rank_candidates()<br/>sin rrf_score en output"]
AspectoModo híbridoModo semántico puro
Se activa cuandoFTS5 retorna ≥1 resultadoFTS5 retorna 0 resultados o no está disponible
Función de scoringrank_hybrid_candidates()rank_candidates()
Relevancia baseCoseno KNN o RRF normalizadoSiempre max(0, 1 - distance)
Campo rrf_scorePresente en cada resultadoAusente
Mejor paraConsultas mixtas (semántico + términos exactos)Consultas conceptuales, sinónimos

Todas las constantes están en src/mcp_memory/scoring.py como variables a nivel de módulo:

ConstanteDefaultPropósito
EXPANSION_FACTOR3Multiplicador de over-retrieval KNN. Si limit=10, se recuperan 30 candidatos para re-ranking
RRF_K60Constante de suavizado RRF. Valor estándar del paper original. Valores más altos suavizan diferencias de rank; valores más bajos amplifican las posiciones superiores

Estas dos constantes controlan directamente el comportamiento de la búsqueda híbrida:

  • EXPANSION_FACTOR: afecta cuántos candidatos recupera cada rama antes de fusionar. Valores más altos mejoran el recall a costa de computación. El paso de re-ranking luego selecciona los mejores limit resultados.
  • RRF_K: controla cuánto premia RRF las posiciones superiores vs. las inferiores. Con k=60, la diferencia entre rank 1 y rank 2 es 1/61 - 1/62 = 0.000265 — pequeña pero acumulativa entre rankings.

Cada resultado incluye el campo rrf_score:

{
"results": [{
"name": "CachorroSpace",
"entityType": "Project",
"observations": ["Built with Astro Starlight", "Accent: teal (#2dd4bf)"],
"limbic_score": 0.67,
"scoring": {
"importance": 0.85,
"temporal_factor": 0.99,
"cooc_boost": 1.23
},
"distance": 0.42,
"rrf_score": 0.018542
}]
}

El campo rrf_score está ausente:

{
"results": [{
"name": "CachorroSpace",
"entityType": "Project",
"observations": ["Built with Astro Starlight", "Accent: teal (#2dd4bf)"],
"limbic_score": 0.52,
"scoring": {
"importance": 0.70,
"temporal_factor": 0.95,
"cooc_boost": 0.80
},
"distance": 0.35
}]
}

La presencia o ausencia de rrf_score es la única diferencia estructural en el output — se puede usar para detectar qué modo se utilizó sin consultar al motor directamente.