Ir al contenido

Búsqueda Semántica

La búsqueda semántica convierte cada entidad del knowledge graph en un vector numérico de 384 dimensiones y lo almacena en una tabla virtual de sqlite-vec. Al invocar search_semantic, el motor codifica la consulta en el mismo espacio vectorial, encuentra los k vecinos más cercanos (KNN) y retorna las entidades más relevantes.

El pipeline completo — desde texto crudo hasta resultados rankeados — funciona así:

graph TD
Input["Entity text or Query"] --> Prefix["Prepend prefix<br/>'query: ' or 'passage: '"]
Prefix --> Tok["Tokenization<br/>HuggingFace fast tokenizer<br/>trunc=512, pad=512"]
Tok --> ONNX["ONNX Forward Pass<br/>CPUExecutionProvider<br/>→ (batch, seq_len, 384)"]
ONNX --> Pool["Mean Pooling<br/>Mask [PAD] via attention_mask<br/>→ (batch, 384)"]
Pool --> Norm["L2 Normalization<br/>||v|| = 1<br/>→ float32[384]"]
Norm --> Store{Entity or Query?}
Store -->|Entity| Vec["Serialize to bytes<br/>384 x 4 = 1,536 bytes<br/>INSERT OR REPLACE into vec0"]
Store -->|Query| KNN["sqlite-vec KNN<br/>WHERE embedding MATCH ?<br/>ORDER BY distance"]
KNN --> Results["[{entity_id, distance}]"]
Results --> Limbic["Limbic Re-rank<br/>salience · temporal · cooc"]
Limbic --> Output["Top-K results with scoring"]

Para el pipeline completo de fusión que combina KNN con búsqueda de texto completo, consulta Búsqueda Híbrida (FTS5 + RRF).

El motor semántico usa intfloat/multilingual-e5-small de IntFloat (familia E5, entrenado por Intel):

PropiedadValor
Model IDintfloat/multilingual-e5-small
Dimensiones384 (float32, ONNX FP32)
Idiomas94+ — español, inglés, francés, alemán, chino, japonés, etc.
RuntimeSolo CPU (CPUExecutionProvider, no requiere GPU)
Tamaño en disco~465 MB (modelo ONNX + tokenizer)
Métrica de distanciaDistancia coseno: d = 1 - cos(A, B), rango [0, 2]
TipoRecuperación asimétrica — requiere prefijos "query: " y "passage: "
Ruta de caché~/.cache/mcp-memory-v2/models/

El método EmbeddingEngine.encode() transforma texto crudo en vectores normalizados con L2 en cinco pasos:

def encode(self, texts: list[str], task: str = "passage") -> np.ndarray:
"""Encode texts to embeddings.
task: "query" prepends "query: " prefix, "passage" prepends "passage: ".
"""
prefix = self.QUERY_PREFIX if task == "query" else self.PASSAGE_PREFIX
prefixed = [f"{prefix}{t}" for t in texts]
# Step 1-4: tokenization → ONNX → mean pooling → L2 normalize

Cada texto de entrada recibe un prefijo específico según el entrenamiento del modelo E5:

  • task="query": añade "query: " al inicio — se usa al codificar consultas de búsqueda
  • task="passage": añade "passage: " al inicio — se usa al codificar texto de entidades (por defecto)

El fast tokenizer de HuggingFace (implementación en Rust) con dos configuraciones fijas:

  • enable_truncation(max_length=512) — trunca secuencias mayores a 512 tokens
  • enable_padding(length=512) — rellena secuencias más cortas con [PAD] hasta exactamente 512 tokens
encoded = self._tokenizer.encode_batch(texts)
input_ids = np.array(
[e.ids for e in encoded],
dtype=np.int64,
)
attention_mask = np.array(
[e.attention_mask for e in encoded],
dtype=np.int64,
)

Todas las secuencias salen del tokenizer como arrays int64 uniformes de forma (batch, 512)input_ids para el modelo y attention_mask para distinguir tokens reales del padding.

Los nombres de entrada se descubren dinámicamente desde el grafo ONNX (self._session.get_inputs()), haciendo el código robusto ante variaciones menores en la exportación del modelo:

feed: dict[str, np.ndarray] = {}
for name in self._input_names:
if name == "input_ids":
feed[name] = input_ids
elif name == "attention_mask":
feed[name] = attention_mask
elif name == "token_type_ids":
feed[name] = np.zeros_like(input_ids)
else:
feed[name] = np.zeros_like(input_ids)
outputs = self._session.run(None, feed)
token_embeddings = outputs[0] # (batch, seq_len, 384)

La sesión se ejecuta en CPUExecutionProvider con graph_optimization_level = ORT_ENABLE_ALL.

Se promedian los embeddings de todos los tokens reales (no [PAD]). La máscara de atención elimina las contribuciones del padding:

mask_expanded = attention_mask[:, :, np.newaxis].astype(np.float32)
sum_embeddings = np.sum(token_embeddings * mask_expanded, axis=1)
sum_mask = np.clip(mask_expanded.sum(axis=1), a_min=1e-9, a_max=None)
mean_embeddings = sum_embeddings / sum_mask

La máscara se expande a 3D (batch, 512, 1) para multiplicación elemento a elemento contra token_embeddings. Los tokens donde attention_mask == 0 (padding) no contribuyen al promedio.

Convierte cada vector en un vector unitario (norma = 1). Esto permite que el producto escalar sirva como proxy directo de la similitud coseno — el distance_metric=cosine que sqlite-vec espera:

norms = np.linalg.norm(mean_embeddings, axis=1, keepdims=True)
norms = np.clip(norms, a_min=1e-9, a_max=None)
normalized = mean_embeddings / norms
return normalized.astype(np.float32) # (batch, 384)

Antes de que una entidad llegue al pipeline de codificación, debe convertirse de datos estructurados a una sola cadena de texto. El sistema usa una estrategia Head+Tail+Diversity para ajustar el contenido más informativo dentro del presupuesto práctico de tokens del modelo.

"{name} ({entity_type}) | {obs1} | {obs2} | ... | Rel: type → target; ..."

Ejemplo:

MCP Memory v2 (Project) | 8 tasks: T1 → T2 → T3 | Pipeline: Architect → Builder → Auditor | Rel: uses → FastMCP; uses → SQLite

El presupuesto es de 480 tokens (MAX_TOKENS = 480), con " | " como separador entre observaciones:

SegmentoContenidoRacional
HeadPrimeras observacionesContenido más importante/estable — típicamente la descripción central de la entidad
TailÚltimas observacionesContenido más reciente — últimas actualizaciones, cambios de estado
DiversityObservaciones intermedias seleccionadasMaximiza la variedad semántica — evita que el embedding se sobreajuste a un tema estrecho

Las relaciones se añaden al final cuando existen, formateadas como Rel: type → target; ....

Los vectores se almacenan en la tabla virtual entity_embeddings de sqlite-vec:

CREATE VIRTUAL TABLE IF NOT EXISTS entity_embeddings
USING vec0(embedding float[384] distance_metric=cosine);
PropiedadValor
Identificadorrowid (implícito, mapea a entities.id)
Métrica de distanciacosine — distancia angular, rango [0, 2]
Tamaño del vector384 × 4 bytes = 1.536 bytes por embedding
Estrategia de upsertINSERT OR REPLACE — sin versiones duplicadas
def serialize_f32(vector: np.ndarray) -> bytes:
"""Pack a float32 vector into raw bytes for sqlite-vec.
A 384-dim vector → 1,536 bytes."""
return struct.pack(f"{len(vector)}f", *vector.flatten())
def deserialize_f32(data: bytes, dim: int = 384) -> np.ndarray:
"""Unpack raw bytes from sqlite-vec back into a float32 vector."""
return np.frombuffer(data, dtype=np.float32).reshape(dim)

Cada vector float32 de 384 dimensiones se serializa en exactamente 1.536 bytes de datos crudos. Almacenar con INSERT OR REPLACE significa que actualizar el embedding de una entidad sobrescribe el valor anterior — no se acumulan versiones obsoletas.

SELECT rowid, distance
FROM entity_embeddings
WHERE embedding MATCH ?
ORDER BY distance
LIMIT ?

El placeholder ? recibe el vector de consulta serializado (1.536 bytes). Los resultados se retornan ordenados por distancia ascendente — los más similares primero. El límite por defecto es 10, configurable vía el parámetro limit.

La métrica de distancia es la distancia coseno, definida como:

d(A, B) = 1 - cos(A, B) = 1 - (A · B) / (||A|| × ||B||)

Dado que los vectores están normalizados con L2 (||A|| = ||B|| = 1), esto se simplifica a d = 1 - A · B.

DistanciaSignificado
0.0Vectores idénticos
< 0.3Muy similares
~ 1.0No relacionados
2.0Vectores opuestos

Después de la recuperación KNN, los resultados pasan por el Sistema Límbico para re-ranking dinámico basado en salience, decay temporal y patrones de co-ocurrencia.

Ejecuta el script de descarga para obtener y exportar el modelo ONNX:

Ventana de terminal
uv run python scripts/download_model.py

Esto descarga cuatro archivos a ~/.cache/mcp-memory-v2/models/:

~/.cache/mcp-memory-v2/models/
├── model.onnx # Modelo ONNX exportado (~465 MB)
├── tokenizer.json # Fast tokenizer de HuggingFace
├── tokenizer_config.json # Configuración del tokenizer
└── special_tokens_map.json # Mapeo de tokens especiales

EmbeddingEngine usa un patrón singleton con carga diferida en dos niveles:

class EmbeddingEngine:
_instance: "EmbeddingEngine | None" = None
@classmethod
def get_instance(cls) -> "EmbeddingEngine":
if cls._instance is None:
cls._instance = cls()
return cls._instance
  1. Nivel de import: mcp_memory.embeddings se importa dentro de _get_engine(), no en el scope del módulo
  2. Nivel de instancia: get_instance() crea el singleton en la primera llamada

Consecuencia práctica:

MomentoComportamiento
Inicio del servidorSiempre rápido — el modelo no se carga
Primera llamada a search_semantic~3–5 segundos extra mientras carga ONNX + tokenizer en memoria
Llamadas subsiguientesMilisegundos — el motor ya está en memoria

El servidor está diseñado para degradarse con elegancia:

EscenarioComportamiento
Modelo descargadoLa búsqueda semántica y la generación de embeddings funcionan con normalidad
Modelo no descargadosearch_semantic retorna un error claro; todas las demás tools funcionan
sqlite-vec no disponibleEl servidor continúa sin búsqueda semántica; las tools CRUD funcionan con normalidad

Los archivos del modelo aún no se han descargado. Ejecuta:

Ventana de terminal
uv run python scripts/download_model.py

Luego reinicia el servidor MCP (o haz otra llamada a search_semantic — el motor reintenta la carga bajo demanda).

La primera búsqueda es lenta (3–5 segundos)

Sección titulada «La primera búsqueda es lenta (3–5 segundos)»

Esto es esperado. El modelo ONNX (~465 MB) y el tokenizer se cargan en memoria en la primera llamada. Las llamadas subsiguientes retornan en milisegundos. No hay forma de pre-calentar el motor además de hacer una búsqueda inicial.

Si la extensión sqlite-vec falla al cargar, el servidor arranca sin capacidad de búsqueda vectorial. Las 10 tools (create_entities, search_nodes, open_nodes, etc.) continúan funcionando. Solo search_semantic se ve afectada.

Los embeddings parecen incorrectos después de actualizar observaciones

Sección titulada «Los embeddings parecen incorrectos después de actualizar observaciones»

Los embeddings se regeneran completamente cuando cambian las observaciones — no se actualizan de forma incremental. Si has añadido muchas observaciones y los resultados no parecen correctos, la entidad puede haber alcanzado el presupuesto de 480 tokens y observaciones más antiguas se descartaron del snapshot. Considera si la información identificativa crítica está en las primeras observaciones de la entidad (el segmento “Head”).