wandres.dev
VECTORIZE · base de datos vectorial

Filtrado por metadata: acotar antes de comparar

La similitud pura casi nunca es la consulta que quieres: necesitas los fragmentos parecidos de este usuario, en este idioma, posteriores a esta fecha. Vectorize aplica el filtro antes de buscar vecinos, de modo que los topK salen del subconjunto ya acotado. Cómo se crean los índices de metadata sin los que ningún filtro funciona, qué operadores existen y qué combinaciones son legales, cómo un rango sobre cadenas se convierte en búsqueda por prefijo, y en qué se diferencian los namespaces de los filtros de metadata.

⏱ 16 min

Una búsqueda por similitud sin restricciones es una curiosidad de laboratorio. En cualquier sistema real la pregunta llega con condiciones pegadas: los documentos de este cliente y no los del vecino, los del idioma que el usuario habla, los publicados en el último trimestre. Vectorize resuelve esto con una decisión de diseño que hay que entender bien, porque gobierna tanto la corrección como el rendimiento: el filtro se evalúa antes que la similitud, y los vecinos más cercanos se eligen dentro del subconjunto que sobrevivió al filtro. No filtras resultados, filtras el espacio de búsqueda.

🎯 Al terminar esta lección sabrás
  • Entender que el filtro se aplica antes de calcular los vecinos más cercanos.
  • Crear índices de metadata y saber por qué sin ellos no existe filtrado.
  • Combinar los operadores de comparación, pertenencia y rango con sus reglas.
  • Distinguir un namespace de un filtro de metadata y elegir el adecuado.

El filtro se aplica antes que la similitud

La propiedad filter de query no es un colador puesto a la salida. Vectorize restringe primero el conjunto de vectores candidatos a los que cumplen la condición y solo después busca dentro de él los topK más parecidos. La diferencia es enorme y se ve con un ejemplo: si pides los cinco fragmentos más parecidos en español y filtrases al final, podrías quedarte con cero resultados porque los cinco vecinos globales estaban en inglés. Filtrando antes, recibes siempre los cinco mejores entre los que sí son españoles.

// el filtro acota el espacio; topK se toma del subconjunto filtrado
const resultados = await env.VECTORIZE.query(vectorConsulta, {
  topK: 5,
  filter: {
    idioma: "es",
    actualizado_en: { $gte: 1767225600 },
  },
  returnMetadata: "indexed",
});

Esa semántica convierte el filtro en una herramienta de corrección, no solo de comodidad. Un sistema multiinquilino donde cada cliente debe ver únicamente sus documentos depende por completo de este orden: si el acotado ocurriera después, un cliente podría recibir una respuesta vacía simplemente porque los vecinos globales pertenecían a otro. La garantía de que se filtra primero es lo que hace que la búsqueda semántica sea utilizable en producción.

Conviene además que el filtro no sea nunca un dato que llegue del cliente tal cual. La condición que aísla a un inquilino debe construirla el servidor a partir de la sesión autenticada, jamás aceptarse desde el cuerpo de la petición, porque un filtro es el mecanismo de aislamiento y no una preferencia de búsqueda. Mezclar ambas cosas en un mismo objeto que el usuario puede influir es la vía más corta a que alguien lea lo que no debe.

Índices de metadata: sin ellos no hay filtro

Hay una trampa que atrapa a todo el mundo una vez. Guardar un campo en la metadata de un vector no lo hace filtrable. Para poder usarlo en un filter tienes que declarar explícitamente un índice de metadata sobre esa propiedad, y puedes crear hasta diez por índice de Vectorize.

# habilita el filtrado sobre una propiedad concreta
npx wrangler vectorize create-metadata-index guia-docs --property-name=idioma --type=string
npx wrangler vectorize create-metadata-index guia-docs --property-name=actualizado_en --type=number

# comprobar cuales hay declarados
npx wrangler vectorize list-metadata-index guia-docs

Diez ranuras es un presupuesto ajustado cuando el sistema crece, así que gástalas con criterio. Un campo merece índice solo si de verdad vas a filtrar por él; el resto de la metadata puede seguir viajando con el vector y devolverse en los resultados sin estar indexada, que es un uso perfectamente legítimo y no consume nada.

Los tipos admitidos son string, number y boolean, y cada uno arrastra sus propias sutilezas. En las cadenas solo se indexan los primeros 64 bytes, recortados en un límite válido de UTF-8, así que un campo largo se vuelve filtrable únicamente por su comienzo: no declares un índice sobre un texto extenso esperando comparar su contenido completo. En los números, la precisión indexada es la de un flotante de 64 bits, más que suficiente para marcas de tiempo, contadores y puntuaciones.

⚠️
Los vectores anteriores al índice no quedan indexados

Un índice de metadata solo alcanza a los vectores escritos después de crearlo. Si tienes un millón de vectores dentro y declaras ahora un índice sobre idioma, ninguno de ellos será filtrable por ese campo hasta que vuelvas a escribirlos con upsert. Por eso el orden correcto al montar un índice es crear el índice vectorial, declarar de inmediato todos los índices de metadata que vas a necesitar y solo entonces empezar a cargar datos. Descubrir esto con el corpus ya cargado significa reindexarlo entero.

Los operadores y sus reglas

El vocabulario de filtrado es el mismo que popularizaron los almacenes documentales, y cubre igualdad, pertenencia a un conjunto y rangos.

Operador Significado Nota de uso
$eq y $ne Igual y distinto Admiten cadena, número, booleano o nulo
$in y $nin Pertenece o no pertenece a un conjunto El valor es un array de esos mismos tipos
$lt y $lte Menor y menor o igual Cota superior de un rango
$gt y $gte Mayor y mayor o igual Cota inferior de un rango

Tres reglas de composición gobiernan lo que puedes escribir. La primera es que un valor suelto equivale a $eq, de modo que escribir el idioma directamente es lo mismo que envolverlo en una comparación explícita. La segunda es que varias claves en el mismo objeto se combinan con una conjunción implícita: todas deben cumplirse. La tercera es que solo se permite emparejar una cota inferior con una superior; cualquier otra combinación de operadores sobre la misma clave es inválida.

// pertenencia a un conjunto y rango cerrado sobre un numero
const filtro = {
  categoria: { $in: ["guias", "referencia"] },
  publicado_en: { $gte: 1764547200, $lt: 1767225600 },
  borrador: false,
};

Dos detalles más que ahorran disgustos. Las claves aceptan un punto para descender por metadata anidada, lo que significa que el punto está reservado y no puede formar parte del nombre de un campo; tampoco pueden empezar por $, contener comillas ni el carácter |. Y la representación compacta del filtro completo tiene que caber en 2048 bytes, un techo generoso salvo que intentes meter una lista de miles de identificadores en un $in.

El truco más elegante del repertorio es aplicar un rango a una cadena. Como las cadenas se ordenan lexicográficamente, combinar una cota inferior con otra ligeramente superior te da una búsqueda por prefijo, que es como se implementa el clásico “todo lo que cuelga de esta carpeta”.

// prefijo: todo lo que empieza por manual/ vive entre manual/ y manual0
const porCarpeta = { ruta: { $gte: "manual/", $lt: "manual0" } };

Ese truco es exactamente el que usan por dentro los sistemas que ofrecen filtrado por carpeta, y funciona porque la cota superior es el mismo prefijo con su último carácter incrementado. Ten presente su límite: como en las cadenas solo se indexan los primeros 64 bytes, el prefijo por el que puedes discriminar tiene esa misma longitud, más que suficiente para rutas y categorías pero insuficiente para jerarquías muy profundas.

Un último aviso de escala. Los rangos sobre corpus enormes, del orden de millones de vectores, pueden perder algo de exactitud, porque combinar una restricción continua con una estructura aproximada tiene su coste. Si tu filtro habitual es un rango de fechas sobre un índice muy grande, suele salir más a cuenta convertir esa dimensión en algo discreto —un campo con el mes o el trimestre y un operador de pertenencia— que apoyarse en comparaciones abiertas.

Namespaces: la partición dura

Junto a los filtros existe un mecanismo distinto y más tajante. Un namespace es una etiqueta única que asignas al vector en el momento de escribirlo y que particiona el índice en segmentos aislados. Un vector pertenece exactamente a un namespace y no puede estar en dos, y el filtro por namespace se aplica antes incluso que el de metadata.

// el namespace se declara al escribir y particiona el indice
await env.VECTORIZE.upsert([
  { id: "art-99#1", values: v, namespace: "cliente-42", metadata: { idioma: "es" } },
]);

// consultar dentro de una sola particion
const soloDeEseCliente = await env.VECTORIZE.query(vectorConsulta, {
  topK: 5,
  namespace: "cliente-42",
  filter: { idioma: "es" },
});
🧱

Namespace

Partición dura y exclusiva. Uno por vector, se fija al escribir, se filtra el primero y admite hasta 50 000 por índice en plan de pago. La herramienta del aislamiento por inquilino.

🏷️

Metadata

Etiquetas múltiples y flexibles. Varios campos por vector, varios tipos, operadores de rango y conjunto. La herramienta de las facetas y los criterios que se combinan.

flowchart LR
Q[vector de consulta] --> NS[filtro por namespace]
NS --> MD[filtro por metadata]
MD --> SUB[subconjunto candidato]
SUB --> TK[topK vecinos mas cercanos]
style NS fill:#f9e2af,color:#11111b
style MD fill:#89b4fa,color:#11111b
style TK fill:#cba6f7,color:#11111b

Los namespaces tienen además dos ventajas operativas que los filtros no ofrecen. La primera es que no consumen ninguna de las diez ranuras de índice de metadata, un presupuesto escaso que conviene reservar para las facetas que de verdad se combinan. La segunda es que no exigen declaración previa: basta con escribir el vector con su namespace para que sea filtrable, sin el paso de crear un índice ni el riesgo de que los vectores anteriores queden fuera. A cambio pagas su rigidez, porque mover un vector de un namespace a otro obliga a reescribirlo entero.

La regla de elección es de naturaleza, no de gusto. Si la dimensión por la que separas es excluyente y define un límite de seguridad —el inquilino, la organización, el entorno—, usa un namespace, porque su unicidad hace estructuralmente imposible que un vector se cuele en el segmento equivocado. Si la dimensión describe el contenido y se combina con otras —idioma, categoría, fecha, autor—, usa metadata, porque ahí quieres flexibilidad y no exclusividad.

El prefiltrado es donde lo discreto y lo continuo por fin se dan la mano

Un índice vectorial y un índice relacional resuelven problemas que parecen irreconciliables. El primero opera sobre un continuo: no hay orden total, no hay igualdad útil, solo proximidad en un espacio de cientos de dimensiones, y su estructura interna se construye para explorar vecindades sin recorrerlo entero. El segundo opera sobre lo discreto: valores comparables, orden lexicográfico o numérico, conjuntos bien definidos, y sus árboles y tablas hash presuponen exactamente esas propiedades. Cualquier sistema de recuperación serio necesita las dos cosas a la vez, y la manera de combinarlas no es un detalle de implementación sino la decisión que determina si el sistema es correcto. Filtrar después de buscar es lo intuitivo y lo equivocado: recuperas los cien vecinos globales, descartas los que no cumplen y descubres que te quedan tres, o ninguno, porque la vecindad geométrica no sabía nada de tus condiciones. El resultado es un sistema que devuelve menos de lo que pediste sin explicar por qué, y cuya calidad se degrada precisamente cuando el filtro es más selectivo, es decir, cuando más lo necesitas. Prefiltrar invierte el orden y con él la garantía: el motor restringe primero el universo a lo que cumple las condiciones discretas y solo entonces despliega la maquinaria geométrica dentro de ese universo, de modo que los topK que recibes son siempre los mejores entre los legítimos. La dificultad técnica de hacerlo es real —los índices aproximados no están construidos para navegar subconjuntos arbitrarios, y por eso hace falta declarar índices de metadata por adelantado—, y ese coste explica todas las restricciones que has visto: el límite de diez propiedades, los 64 bytes indexados por cadena, la necesidad de reescribir los vectores anteriores. No son caprichos, son el precio de que lo continuo respete las fronteras que lo discreto dibuja. Entender esto te da el criterio para juzgar cualquier base vectorial que te encuentres en el futuro: pregúntale si filtra antes o después, y sabrás si sirve para construir sistemas reales.

⚔️ Acota el espacio
  1. Declara índices de metadata sobre un campo de tipo cadena y otro numérico, y comprueba con list-metadata-index que quedaron registrados.
  2. Reescribe con upsert unos vectores que ya estuvieran en el índice y verifica que ahora sí responden al filtro, cosa que antes no ocurría.
  3. Construye un filtro que combine pertenencia a un conjunto con un rango cerrado de fechas y razona por qué esas dos cotas sí pueden convivir.
  4. Diseña el esquema de un buscador multiinquilino decidiendo qué dimensión va a namespace y cuáles a metadata, y justifica cada asignación.