Búsqueda Semántica
Cómo Funciona
Sección titulada «Cómo Funciona»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).
Modelo de Embeddings
Sección titulada «Modelo de Embeddings»El motor semántico usa intfloat/multilingual-e5-small de IntFloat (familia E5, entrenado por Intel):
| Propiedad | Valor |
|---|---|
| Model ID | intfloat/multilingual-e5-small |
| Dimensiones | 384 (float32, ONNX FP32) |
| Idiomas | 94+ — español, inglés, francés, alemán, chino, japonés, etc. |
| Runtime | Solo CPU (CPUExecutionProvider, no requiere GPU) |
| Tamaño en disco | ~465 MB (modelo ONNX + tokenizer) |
| Métrica de distancia | Distancia coseno: d = 1 - cos(A, B), rango [0, 2] |
| Tipo | Recuperación asimétrica — requiere prefijos "query: " y "passage: " |
| Ruta de caché | ~/.cache/mcp-memory-v2/models/ |
Pipeline de Codificación
Sección titulada «Pipeline de Codificación»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 normalizePaso 1 — Prepend del Prefijo
Sección titulada «Paso 1 — Prepend del Prefijo»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úsquedatask="passage": añade"passage: "al inicio — se usa al codificar texto de entidades (por defecto)
Paso 2 — Tokenización
Sección titulada «Paso 2 — Tokenización»El fast tokenizer de HuggingFace (implementación en Rust) con dos configuraciones fijas:
enable_truncation(max_length=512)— trunca secuencias mayores a 512 tokensenable_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.
Paso 3 — Forward Pass ONNX
Sección titulada «Paso 3 — Forward Pass ONNX»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.
Paso 4 — Mean Pooling
Sección titulada «Paso 4 — Mean Pooling»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_maskLa 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.
Paso 5 — Normalización L2
Sección titulada «Paso 5 — Normalización L2»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)Preparación del Texto de Entidad
Sección titulada «Preparación del Texto de Entidad»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.
Formato
Sección titulada «Formato»"{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 → SQLiteEstrategia Head+Tail+Diversity
Sección titulada «Estrategia Head+Tail+Diversity»El presupuesto es de 480 tokens (MAX_TOKENS = 480), con " | " como separador entre observaciones:
| Segmento | Contenido | Racional |
|---|---|---|
| Head | Primeras observaciones | Contenido más importante/estable — típicamente la descripción central de la entidad |
| Tail | Últimas observaciones | Contenido más reciente — últimas actualizaciones, cambios de estado |
| Diversity | Observaciones intermedias seleccionadas | Maximiza 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; ....
Búsqueda KNN con sqlite-vec
Sección titulada «Búsqueda KNN con sqlite-vec»Los vectores se almacenan en la tabla virtual entity_embeddings de sqlite-vec:
CREATE VIRTUAL TABLE IF NOT EXISTS entity_embeddingsUSING vec0(embedding float[384] distance_metric=cosine);Detalles de Almacenamiento
Sección titulada «Detalles de Almacenamiento»| Propiedad | Valor |
|---|---|
| Identificador | rowid (implícito, mapea a entities.id) |
| Métrica de distancia | cosine — distancia angular, rango [0, 2] |
| Tamaño del vector | 384 × 4 bytes = 1.536 bytes por embedding |
| Estrategia de upsert | INSERT OR REPLACE — sin versiones duplicadas |
Serialización
Sección titulada «Serialización»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.
Consulta KNN
Sección titulada «Consulta KNN»SELECT rowid, distanceFROM entity_embeddingsWHERE embedding MATCH ?ORDER BY distanceLIMIT ?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.
Distancia Coseno
Sección titulada «Distancia Coseno»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.
| Distancia | Significado |
|---|---|
0.0 | Vectores idénticos |
< 0.3 | Muy similares |
~ 1.0 | No relacionados |
2.0 | Vectores 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.
Configuración
Sección titulada «Configuración»Descargar el Modelo
Sección titulada «Descargar el Modelo»Ejecuta el script de descarga para obtener y exportar el modelo ONNX:
uv run python scripts/download_model.pyEsto 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 especialesComportamiento de Carga Diferida
Sección titulada «Comportamiento de Carga Diferida»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- Nivel de import:
mcp_memory.embeddingsse importa dentro de_get_engine(), no en el scope del módulo - Nivel de instancia:
get_instance()crea el singleton en la primera llamada
Consecuencia práctica:
| Momento | Comportamiento |
|---|---|
| Inicio del servidor | Siempre rápido — el modelo no se carga |
Primera llamada a search_semantic | ~3–5 segundos extra mientras carga ONNX + tokenizer en memoria |
| Llamadas subsiguientes | Milisegundos — el motor ya está en memoria |
Ejecutar Sin el Modelo
Sección titulada «Ejecutar Sin el Modelo»El servidor está diseñado para degradarse con elegancia:
| Escenario | Comportamiento |
|---|---|
| Modelo descargado | La búsqueda semántica y la generación de embeddings funcionan con normalidad |
| Modelo no descargado | search_semantic retorna un error claro; todas las demás tools funcionan |
| sqlite-vec no disponible | El servidor continúa sin búsqueda semántica; las tools CRUD funcionan con normalidad |
Resolución de Problemas
Sección titulada «Resolución de Problemas»”Embedding model not available”
Sección titulada «”Embedding model not available”»Los archivos del modelo aún no se han descargado. Ejecuta:
uv run python scripts/download_model.pyLuego 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.
sqlite-vec no disponible
Sección titulada «sqlite-vec no disponible»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”).
Relacionados
Sección titulada «Relacionados»- Búsqueda Híbrida (FTS5 + RRF) — combinación de KNN con búsqueda de texto completo BM25 vía Reciprocal Rank Fusion
- Sistema Límbico — re-ranking dinámico con salience, decay temporal y co-ocurrencia
- Arquitectura — arquitectura completa del sistema y descripción de componentes
- Referencia de Tools — parámetros y formato de retorno de la tool
search_semantic