> ## Content Index
> Fetch the complete content index at: https://blog.devutilites.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Cloudflare Vectorize a fondo: base de datos vectorial para búsqueda semántica y RAG
- URL: https://blog.devutilites.com/cloudflare-vectorize-a-fondo/
- Published: 2026-10-03T03:13:59.000Z
- Updated: 2026-10-03T03:13:59.000Z
- Description: Qué es Vectorize y cómo funciona: índices de vectores con dimensiones y métrica fijas, upsert asíncrono, consultas topK, metadatos, filtros y namespaces. Límites, precios por dimensiones y un RAG completo con Workers AI y tests que encontraron un bug.
- Author: Luis Collantes
- Tags: Cloudflare, IA

Un buscador normal encuentra palabras. Si alguien busca «cómo evito pagar por la salida de datos» en un blog que habla de *egress*, no encuentra nada. La **búsqueda semántica** busca por significado: convierte cada texto en un **vector** (una lista de números, el *embedding*) de forma que textos parecidos dan vectores cercanos, y luego busca los vectores más próximos al de la pregunta.

Para eso hace falta una **base de datos vectorial**, y **Vectorize** es la de Cloudflare: guardas vectores con sus metadatos y, en cada consulta, te devuelve los más parecidos. Con [Workers AI](https://blog.devutilites.com/cloudflare-workers-ai-a-fondo/) para generar los embeddings y un modelo de lenguaje para redactar la respuesta, tienes un **RAG** (*Retrieval-Augmented Generation*): un asistente que responde con tus documentos, no con lo que se inventa.

En este artículo vemos cómo funciona Vectorize, sus límites y precios, y un RAG completo para preguntar a este blog, con sus tests.

🧪

**Este RAG no se ha ejecutado contra Vectorize ni Workers AI reales.** Ninguno de los dos tiene simulación local: incluso con `wrangler dev`, los bindings van a tu cuenta, y no he usado una cuenta para estas pruebas. Lo que sí está comprobado, el 3 de octubre de 2026, con **Wrangler 4.147.0**, **TypeScript 7.0.2** y **Vitest 5.0.3**: el código **compila contra los tipos oficiales** y pasa **7 tests con dobles** de `env.AI` y `env.VECTORIZE` (objetos falsos que imitan sus respuestas, con una similitud coseno de verdad). Los tests encontraron un error real en el troceado, que está corregido. Precios y límites comprobados en la documentación oficial ese mismo día.

## Cómo funciona

![Esquema de un RAG con Vectorize: para indexar, el texto se trocea, se convierte en embeddings con bge-m3 y se guarda con upsert en el índice, que tiene dimensiones y métrica fijas y metadatos; para preguntar, se calcula el embedding de la pregunta, se consulta el índice con topK y filtros y los trozos encontrados se pasan como contexto a un modelo de lenguaje](https://blog.devutilites.com/content/images/2026/10/cf-vectorize-esquema.png)

Indexas una vez; en cada pregunta, un embedding, una consulta y una llamada al modelo. Diagrama: devutilites.

### El índice

Un índice se crea con dos parámetros que **no se pueden cambiar después**:

- **Dimensiones**: la longitud de los vectores. Tiene que coincidir con la salida del modelo de embeddings: 768 para `bge-base-en-v1.5`, 1.024 para `bge-m3`. Máximo, 1.536.
- **Métrica**: cómo se mide la distancia. `cosine` (la habitual con embeddings de texto; cuanto más cerca de 1, más parecido), `euclidean` (cuanto más cerca de 0, más parecido) o `dot-product`.

```bash
npx wrangler vectorize create blog-rag --dimensions=1024 --metric=cosine
npx wrangler vectorize create-metadata-index blog-rag --property-name=tag --type=string
```

Para algunos modelos hay **presets** que fijan las dos cosas por ti (`--preset @cf/baai/bge-base-en-v1.5`), pero solo para `bge-small-en`, `bge-base-en`, `bge-large-en` y dos modelos externos; `bge-m3` no está.

💡

**La documentación de Cloudflare no dice cuántas dimensiones tiene `bge-m3`.** Las 1.024 salen de la ficha del modelo original de BAAI. Antes de crear el índice, compruébalo: la respuesta de `env.AI.run("@cf/baai/bge-m3", …)` incluye un campo `shape` con `[número de textos, dimensiones]`.

### Vectores, metadatos y namespaces

Cada vector tiene un **id** (hasta 64 bytes), sus **valores** y, opcionalmente, **metadatos** (hasta 10 KiB: el texto original, el título, la URL…) y un **namespace**, que separa los vectores en grupos (por cliente, por idioma) y se puede usar para filtrar sin configurar nada.

Para filtrar por otro campo de los metadatos hace falta un **índice de metadatos** sobre ese campo (como mucho 10 por índice, de tipo `string`, `number` o `boolean`). Los filtros admiten `$eq`, `$ne`, `$in`, `$nin`, `$lt`, `$lte`, `$gt` y `$gte`.

⚠️

**Crea los índices de metadatos ANTES de insertar.** Los vectores que ya estaban en el índice no se añaden al índice de metadatos: tendrás que volver a hacer `upsert` de todos. Y de un campo de texto solo se indexan los primeros 64 bytes.

### Escribir y consultar

- **`insert`** añade vectores y falla con los ids que ya existen; **`upsert`** los añade o **sustituye entero** el vector con el mismo id (no mezcla metadatos). Los dos son **asíncronos**: devuelven un `mutationId` y los vectores tardan unos segundos en aparecer en las consultas.
- `**query(vector, opciones)**` devuelve los `topK` más parecidos (5 por defecto) con su `score`. Con `returnMetadata: "all"` trae también los metadatos, pero entonces `topK` máximo es 50 (100 sin valores ni metadatos).
- **`queryById`**, **`getByIds`**, **`deleteByIds`** y `**describe**` completan la API. Para listar ids, `wrangler vectorize list-vectors` pagina de 1.000 en 1.000.

## Límites y precios

| Límite                 | Valor                                         |
| ---------------------- | --------------------------------------------- |
| Índices por cuenta     | 100 (Free) · 50.000 (Paid)                    |
| Vectores por índice    | 20 millones                                   |
| Dimensiones por vector | 1.536 (float32)                               |
| Metadatos por vector   | 10 KiB                                        |
| topK máximo            | 100 sin valores ni metadatos · 50 con ellos   |
| Vectores por upsert    | 1.000 desde un Worker · 5.000 por la API HTTP |
| Namespaces por índice  | 1.000 (Free) · 50.000 (Paid)                  |
| Índices de metadatos   | 10 por índice                                 |

Vectorize cobra por **dimensiones**, no por índices, ni por horas, ni por CPU:

|                         | Workers Free       | Workers Paid                                                |
| ----------------------- | ------------------ | ----------------------------------------------------------- |
| Dimensiones consultadas | 30 millones al mes | 50 millones incluidas, después 0,01 $ por millón            |
| Dimensiones almacenadas | 5 millones         | 10 millones incluidas, después 0,05 $ por cada 100 millones |

Según la documentación, las **dimensiones consultadas** de un mes son **(consultas + vectores almacenados) × dimensiones**, y las almacenadas, vectores × dimensiones. Para este blog: 30 artículos troceados en unos 600 trozos de 1.024 dimensiones son **614.400 dimensiones almacenadas**, muy lejos de los 5 millones gratis. Con 3.000 preguntas al mes, (3.000 + 600) × 1.024 ≈ **3,7 millones de dimensiones consultadas**, también dentro de lo gratuito. La documentación pone como ejemplo de producción 50.000 vectores de 768 dimensiones y 200.000 consultas al mes: 1,94 $.

## El código: un RAG para el blog

La configuración tiene tres bindings: Workers AI, el índice de Vectorize y el nombre del gateway de [AI Gateway](https://blog.devutilites.com/cloudflare-ai-gateway-a-fondo/) por el que pasan las llamadas al modelo:

```json
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "rag-blog",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "ai": { "binding": "AI" },
  "vectorize": [{ "binding": "VECTORIZE", "index_name": "blog-rag" }],
  "vars": { "GATEWAY_ID": "blog" }
}
```

### Trocear

Un artículo entero no cabe bien en un vector: el embedding de 2.000 palabras es una media borrosa de todo. Lo habitual es partirlo en **trozos** de unos cientos de caracteres, respetando párrafos y con un pequeño **solape** para no cortar una idea por la mitad:

```typescript
/**
 * Parte un texto en trozos de como mucho `max` caracteres, respetando párrafos
 * cuando se puede y repitiendo `solape` caracteres entre trozos consecutivos
 * para no cortar una idea por la mitad.
 */
export function trocear(texto: string, max = 800, solape = 120): string[] {
  const parrafos = texto
    .split(/\n\s*\n/)
    .map((p) => p.replace(/\s+/g, " ").trim())
    .filter(Boolean);

  const trozos: string[] = [];
  let actual = "";

  const cerrar = () => {
    if (actual) trozos.push(actual);
    // el siguiente trozo empieza con la cola del anterior (solape)
    actual = actual.length > solape ? actual.slice(-solape) : "";
  };

  for (const p of parrafos) {
    // párrafos gigantes: piezas de `max - solape - 1` para que quepan tras el solape y el espacio
    const tope = max - solape - 1;
    const piezas = p.length > tope ? (p.match(new RegExp(`.{1,${tope}}`, "g")) ?? []) : [p];
    for (const pieza of piezas) {
      if (actual.length + pieza.length + 1 > max) cerrar();
      actual = actual ? `${actual} ${pieza}` : pieza;
    }
  }
  if (actual && actual !== trozos.at(-1)?.slice(-actual.length)) trozos.push(actual);
  return trozos;
}
```

🐛

**Los tests encontraron un error aquí.** La primera versión cortaba los párrafos gigantes en piezas de `max - solape` caracteres; al pegar el solape y un espacio delante, el trozo salía de **801** caracteres con un máximo de 800\. El test «corta párrafos gigantes» falló, y la corrección es el `- 1` de `tope`. Por cosas así merece la pena probar aunque no haya simulador.

### Indexar y preguntar

```typescript
import { trocear } from "./trocear";

// Tipamos el entorno a mano: `wrangler types` (4.147) genera VectorizeIndex,
// que es el tipo de la beta (V1). Para índices V2 el tipo correcto es Vectorize.
export interface Entorno {
  AI: Ai;
  VECTORIZE: Vectorize;
  GATEWAY_ID: string;
}

export const MODELO_EMBEDDINGS = "@cf/baai/bge-m3"; // multilingüe, va bien en español
export const MODELO_LLM = "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
export const UMBRAL = 0.5; // similitud coseno mínima: heurística, ajústala con tus datos

interface Documento {
  id: string;
  titulo: string;
  url: string;
  tag?: string;
  texto: string;
}

async function incrustar(env: Entorno, textos: string[]): Promise<number[][]> {
  const salida = await env.AI.run(
    MODELO_EMBEDDINGS,
    { text: textos },
    { gateway: { id: env.GATEWAY_ID } },
  );
  if (!("data" in salida) || !salida.data) throw new Error("respuesta de embeddings inesperada");
  return salida.data;
}

async function indexar(env: Entorno, doc: Documento): Promise<Response> {
  // el id de cada vector es "<doc>#<n>" y Vectorize admite como mucho 64 bytes
  if (new TextEncoder().encode(`${doc.id}#9999`).length > 64) {
    return Response.json({ error: "id demasiado largo (máx. 64 bytes con el sufijo)" }, { status: 400 });
  }
  const trozos = trocear(doc.texto);
  const mutaciones: string[] = [];

  // lotes de 50 trozos por llamada: prudente, no es un límite documentado
  for (let i = 0; i < trozos.length; i += 50) {
    const lote = trozos.slice(i, i + 50);
    const vectores = await incrustar(env, lote);
    const { mutationId } = await env.VECTORIZE.upsert(
      vectores.map((values, j) => ({
        id: `${doc.id}#${i + j}`,
        values,
        metadata: { doc: doc.id, titulo: doc.titulo, url: doc.url, tag: doc.tag ?? "", texto: lote[j] },
      })),
    );
    mutaciones.push(mutationId);
  }
  // upsert es asíncrono: los vectores tardan unos segundos en poder consultarse
  return Response.json({ trozos: trozos.length, mutaciones }, { status: 202 });
}

async function preguntar(env: Entorno, pregunta: string, tag: string | null): Promise<Response> {
  const [vector] = await incrustar(env, [pregunta]);
  const { matches } = await env.VECTORIZE.query(vector, {
    topK: 4,
    returnMetadata: "all",
    // filtrar por tag exige un metadata index sobre "tag" creado ANTES de insertar
    ...(tag ? { filter: { tag: { $eq: tag } } } : {}),
  });

  const utiles = matches.filter((m) => m.score >= UMBRAL);
  if (utiles.length === 0) {
    // sin contexto no llamamos al LLM: ni gastamos neuronas ni le dejamos inventar
    return Response.json({ respuesta: "No encuentro nada sobre eso en el blog.", fuentes: [] });
  }

  const contexto = utiles
    .map((m, i) => `[${i + 1}] ${m.metadata?.titulo}\n${m.metadata?.texto}`)
    .join("\n\n");

  const salida = await env.AI.run(
    MODELO_LLM,
    {
      messages: [
        {
          role: "system",
          content:
            "Eres el asistente del blog. Responde en español y SOLO con la información del contexto. " +
            "Cita las fuentes como [1], [2]… Si el contexto no basta, dilo.\n\nContexto:\n" + contexto,
        },
        { role: "user", content: pregunta },
      ],
      max_tokens: 400,
    },
    {
      gateway: {
        id: env.GATEWAY_ID,
        cacheTtl: 3600, // misma pregunta exacta → respuesta desde la caché del gateway
        metadata: { ruta: "preguntar" },
      },
    },
  );

  // el tipo de salida es una unión: objeto con `response`, string o respuesta de lote
  const respuesta =
    typeof salida === "string" ? salida : "response" in salida ? salida.response : null;
  return Response.json({
    respuesta,
    fuentes: utiles.map((m) => ({
      titulo: m.metadata?.titulo,
      url: m.metadata?.url,
      score: Number(m.score.toFixed(3)),
    })),
  });
}

export default {
  async fetch(request, env): Promise<Response> {
    const url = new URL(request.url);

    if (request.method === "POST" && url.pathname === "/indexar") {
      const doc = (await request.json()) as Documento;
      if (!doc?.id || !doc.texto) return Response.json({ error: "faltan id o texto" }, { status: 400 });
      return indexar(env, doc);
    }

    if (request.method === "GET" && url.pathname === "/preguntar") {
      const q = url.searchParams.get("q")?.trim();
      if (!q) return Response.json({ error: "falta ?q=" }, { status: 400 });
      return preguntar(env, q, url.searchParams.get("tag"));
    }

    return new Response("POST /indexar · GET /preguntar?q=…\n", { status: 404 });
  },
} satisfies ExportedHandler<Entorno>;
```

Las decisiones importantes:

- **El tipo del entorno está escrito a mano.** `wrangler types` genera `VECTORIZE: VectorizeIndex`, que según sus propios comentarios es el tipo de la **beta** (índices V1, obsoletos). Para un índice actual, el tipo correcto es `Vectorize`.
- **Ids `documento#n`**, comprobando antes que caben en los 64 bytes.
- **El texto del trozo va en los metadatos**, para no tener que buscarlo en otra base de datos al responder.
- **Umbral de similitud.** Si ningún trozo pasa de 0,5, no se llama al modelo: no gasta neuronas y no le das ocasión de inventarse una respuesta. El 0,5 es una heurística; ajústalo con preguntas reales.
- **`upsert` es asíncrono.** Si indexas y preguntas al momento, puede que no encuentre nada. Por eso `/indexar` responde 202 con los `mutationId`.

### Los tests

Los dobles imitan la forma de las respuestas: el de Workers AI convierte cada texto en un vector según qué palabras contiene, y el de Vectorize guarda los vectores en memoria y ordena por similitud coseno. No sustituyen a una prueba real (ni la calidad de `bge-m3` ni la de Llama están aquí), pero comprueban toda la lógica del Worker:

```text
 ✓ test/rag.test.ts > trocear > respeta el máximo, solapa y no deja trozos vacíos
 ✓ test/rag.test.ts > trocear > corta párrafos gigantes
 ✓ test/rag.test.ts > worker RAG (con dobles) > indexa: un vector por trozo, id doc#n y metadatos
 ✓ test/rag.test.ts > worker RAG (con dobles) > rechaza ids que no caben en 64 bytes
 ✓ test/rag.test.ts > worker RAG (con dobles) > pregunta: recupera, pasa el contexto al LLM por el gateway y devuelve fuentes
 ✓ test/rag.test.ts > worker RAG (con dobles) > sin coincidencias por encima del umbral no llama al LLM
 ✓ test/rag.test.ts > worker RAG (con dobles) > filtra por tag
      Tests  7 passed (7)
```

Para usarlo de verdad: crear el índice y el índice de metadatos (los comandos de arriba), `npx wrangler deploy`, mandar cada artículo a `/indexar` y preguntar con `/preguntar?q=…&tag=Cloudflare`. Cada paso gasta de tu cuota.

## Errores comunes (y cómo evitarlos)

- **Dimensiones que no coinciden con el modelo.** Un índice de 768 con embeddings de 1.024 falla al insertar. Y no se puede cambiar: hay que crear otro índice.
- **Cambiar de modelo de embeddings sin reindexar.** Los vectores de modelos distintos no son comparables, aunque tengan las mismas dimensiones.
- **Crear el índice de metadatos después de insertar.** Los filtros no verán los vectores antiguos.
- **Preguntar justo después de insertar.** Las escrituras son asíncronas.
- **Trozos demasiado grandes o demasiado pequeños.** Grandes: el vector no representa nada concreto. Pequeños: el modelo recibe frases sueltas sin contexto. Entre 500 y 1.000 caracteres con solape es un buen punto de partida.
- **Pedir `returnMetadata: "all"` con `topK` 100.** El máximo con metadatos es 50.
- **No guardar el texto.** Si en los metadatos solo hay un id, tendrás que consultar otra base de datos para cada trozo antes de llamar al modelo.

## Cuándo usar Vectorize (y cuándo no)

**Úsalo** para búsqueda semántica, RAG sobre tus documentos, recomendaciones («artículos parecidos») o detección de duplicados, sobre todo si ya usas Workers y Workers AI: todo queda en la misma cuenta, sin servidores y con una cuota gratuita generosa.

**No lo necesitas** si tu búsqueda es por palabras exactas (el índice de texto completo de [D1](https://blog.devutilites.com/cloudflare-d1-a-fondo/), con FTS5, es más simple) o si tienes pocos cientos de vectores y ya usas PostgreSQL con pgvector a través de [Hyperdrive](https://blog.devutilites.com/cloudflare-hyperdrive-a-fondo/).

## Sigue leyendo

- [Cloudflare a fondo](https://blog.devutilites.com/cloudflare-a-fondo/): la visión general de la plataforma.
- [Cloudflare Workers AI a fondo](https://blog.devutilites.com/cloudflare-workers-ai-a-fondo/): los modelos de embeddings y de lenguaje del RAG.
- [Cloudflare AI Gateway a fondo](https://blog.devutilites.com/cloudflare-ai-gateway-a-fondo/): caché y logs para las llamadas al modelo.
- [Cloudflare Workflows a fondo](https://blog.devutilites.com/cloudflare-workflows-a-fondo/): para indexar archivos grandes paso a paso, con reintentos.
- [Cloudflare D1 a fondo](https://blog.devutilites.com/cloudflare-d1-a-fondo/): cuando basta con buscar palabras.