~/ / Artículos de IA / LLM locales en un VPS / Guarda y busca embeddings de Ollama en PostgreSQL con pgvector

Guarda y busca embeddings de Ollama en PostgreSQL con pgvector

Los embeddings de /api/embed de Ollama solo sirven para buscar cuando están guardados en algún sitio. pgvector es una extensión de código abierto que añade a PostgreSQL la búsqueda por similitud de vectores, de modo que los vectores pueden estar en la misma base de datos que los textos y el resto de tus datos, en el mismo servidor. Este artículo explica cómo instalarla, crear la tabla, elegir la distancia y el índice, y los ajustes que cambian los resultados, según el README de pgvector para la versión 0.8.7. Después pasa los resultados de la búsqueda a /api/chat de Ollama para que la respuesta nombre sus fuentes, y comprueba esas citas. Ejecutamos el SQL en pgvector 0.8.7 con PostgreSQL 16, con vectores reales de Ollama, y las peticiones de chat en Ollama 0.40.11 con qwen2.5:7b. Cuatro herramientas de esta página escriben el SQL y la petición y comprueban la respuesta en tu navegador.

Publicado: · 11 min de lectura · Datos comprobados con el README y el código fuente de pgvector en la versión 0.8.7 y con la documentación y el código fuente de Ollama (0.40.11) el 2026-11-05. Consulta la documentación actual antes de fiarte de ellos.

Qué añade pgvector a PostgreSQL

El README de pgvector lo describe como búsqueda por similitud de vectores de código abierto para Postgres. Admite búsqueda exacta y aproximada de vecinos más cercanos; vectores de precisión simple, media precisión, binarios y dispersos; y distancia L2, producto interno, distancia del coseno, distancia L1, distancia de Hamming y distancia de Jaccard. Como los vectores son columnas normales de PostgreSQL, también cuentan con cumplimiento ACID, recuperación en un punto en el tiempo y JOINs.

Para una búsqueda en tus documentos, guardas una fila por fragmento con su texto y su vector, conviertes la pregunta en un embedding con el mismo modelo y ordenas las filas por su distancia al vector de la pregunta.

Instalarlo

pgvector admite PostgreSQL 13 y posteriores. El README enumera varias formas de instalarlo, entre ellas estas:

MétodoQué dice el README
Compilar desde el código fuentegit clone --branch v0.8.7, y después make y make install (puede hacer falta sudo)
DockerLa imagen pgvector/pgvector añade pgvector a la imagen de Postgres. Etiquetas como pg18-trixie o 0.8.7-pg16 eligen la versión de Postgres.
APT (Debian, Ubuntu)sudo apt install postgresql-18-pgvector desde el repositorio APT de PostgreSQL, sustituyendo 18 por tu versión de Postgres
Homebrewbrew install pgvector, que solo lo añade a las fórmulas postgresql@18 y postgresql@17

Después, actívalo una vez en cada base de datos en la que quieras usarlo, con CREATE EXTENSION vector;. Para este artículo usamos la imagen de Docker pgvector/pgvector:0.8.7-pg16.

Crear la tabla

Una columna de tipo vector(n) guarda vectores con exactamente n dimensiones, y n debe ser la longitud de los vectores de tu modelo de embeddings. Un vector con otra longitud se rechaza: cuando insertamos un vector de 2 dimensiones en una columna vector(3), PostgreSQL devolvió "expected 3 dimensions, not 2". all-minilm devuelve 384 dimensiones, así que su columna es vector(384).

El README indica que cada vector ocupa 4 × dimensiones + 8 bytes, con cada elemento como un número de coma flotante de precisión simple, y permite hasta 16,000 dimensiones en una columna. Un índice admite menos dimensiones, como se ve más abajo. El tipo halfvec guarda valores de media precisión en 2 × dimensiones + 8 bytes.

Dimensionesvectorhalfvec
3841,544 bytes776 bytes
7683,080 bytes1,544 bytes
10244,104 bytes2,056 bytes

En nuestra tabla de prueba, pg_column_size dio 1,544 bytes para vector(384) y 776 bytes para halfvec(384), de acuerdo con la fórmula. Esto es solo el vector; la columna de texto y los índices ocupan espacio adicional.

Cargar los vectores de Ollama

pgvector lee un vector como texto entre corchetes, como '[1,2,3]', y el README dice que todos los elementos deben ser finitos (sin NaN, Infinity ni -Infinity). Cada array del campo embeddings de una respuesta de /api/embed ya tiene esa forma, y la respuesta conserva el orden de la entrada, así que el primer texto corresponde al primer vector.

El README muestra INSERT para unas pocas filas, INSERT … ON CONFLICT para upserts y COPY para cargas masivas. El conversor de abajo empareja tus textos con la respuesta y escribe sentencias INSERT con las comillas escapadas. Ejecutamos su resultado con textos que contenían comillas, barras invertidas, saltos de línea y emoji, y volvieron sin cambios.

Pruébalo: Embeddings to pgvector INSERT

El ejemplo usa vectores inventados, de longitud unitaria y con 4 dimensiones para que se puedan leer. Los embeddings reales tienen cientos o miles de dimensiones.

3 filas con 4 dimensiones. Textos leídos como el cuerpo de la petición a /api/embed, vectores como una respuesta de Ollama /api/embed.

Sentencias INSERT

Ejecútalo en psql u otro cliente. Las filas se insertan en una sola transacción.

BEGIN;

INSERT INTO documents (content, embedding) VALUES
  ('Embeddings turn text into numeric vectors.', '[0.620186,0.410123,0.550165,0.380114]'),
  ('pgvector stores vectors in PostgreSQL, next to the rest of your data.', '[0.578815,0.469039,0.518937,0.419142]'),
  ('Ollama''s /api/embed returns one vector per input, in the same order.', '[0.119982,0.809879,-0.329951,0.46993]');

COMMIT;

Formato de vector del README de pgvector, comprobado con pgvector 0.8.7 el 2026-11-04. README de pgvector

Todo se hace en tu navegador. La herramienta escribe SQL y no se conecta a nada.

Elegir la distancia

La documentación de Ollama recomienda la similitud del coseno para la mayoría de los casos de búsqueda semántica y dice que /api/embed devuelve vectores de longitud 1. Para esos vectores, la distancia del coseno, la distancia L2 y el producto interno ordenan las filas igual, porque la distancia L2 al cuadrado es 2 menos 2 veces la similitud del coseno. En nuestra prueba con all-minilm, las tres primeras filas fueron las mismas con las tres, y la distancia L2 de 0.931 coincidió con la similitud del coseno de 0.566.

Cada distancia tiene su propio operador. Una búsqueda ordena por el operador y toma las primeras filas con LIMIT, y un índice se crea para una clase de operador.

OperadorDevuelveClase de operador del índice
<=>Distancia del coseno. La similitud del coseno es 1 - (a <=> b).vector_cosine_ops
<->Distancia L2 (euclídea)vector_l2_ops
<#>El producto interno negativo. Multiplica por -1 para obtener el producto interno.vector_ip_ops
<+>Distancia L1vector_l1_ops

El README explica que <#> devuelve el producto interno negativo porque Postgres solo admite escaneos de índice en orden ASC con operadores. Para halfvec, las clases de operador son halfvec_cosine_ops, halfvec_l2_ops y así sucesivamente.

Búsqueda exacta o con índice

Sin índice, pgvector hace una búsqueda exacta de vecinos más cercanos, que según el README ofrece una exhaustividad (recall) perfecta. Un índice aproximado cambia algo de exhaustividad por velocidad, y el README advierte de que las consultas pueden devolver resultados distintos después de añadir uno.

Un índice solo se usa cuando la consulta tiene ORDER BY y LIMIT y el ORDER BY es el propio operador de distancia en orden ascendente. ORDER BY embedding <=> $1 LIMIT 5 puede usar el índice; ORDER BY 1 - (embedding <=> $1) DESC no puede. El README también señala que un escaneo de la tabla puede ser más rápido cuando la tabla es pequeña.

PuntoHNSWIVFFlat
Cómo funcionaUn grafo de varias capasDivide los vectores en listas y busca en las listas más cercanas a la consulta
En comparación con el otroMejor equilibrio entre velocidad y exhaustividad, pero tarda más en construirse y usa más memoriaSe construye más rápido y usa menos memoria, pero con un peor equilibrio entre velocidad y exhaustividad
Cuándo crearloSe puede crear en una tabla vacía (sin paso de entrenamiento)Cuando la tabla ya tiene algunos datos
Opciones de construcciónm (16 por defecto) y ef_construction (64 por defecto)lists: empieza con rows / 1000 hasta 1M filas, y con sqrt(rows) por encima de eso
Opción de búsquedahnsw.ef_search, 40 por defectoivfflat.probes, 1 por defecto; empieza con sqrt(lists)
Dimensionesvector hasta 2,000, halfvec hasta 4,000vector hasta 2,000, halfvec hasta 4,000

En pgvector 0.8.7 también confirmamos que una columna vector(2001) no puede tener un índice HNSW, que IVFFlat no tiene clase de operador para la distancia L1 y que ef_construction debe ser al menos 2 × m. Para más dimensiones, las preguntas frecuentes (FAQ) del README enumeran halfvec, la cuantización binaria y otras opciones.

Pruébalo: pgvector SQL Builder

Cada vector ocupa 1,544 bytes, así que 10,000 filas ocupan unos 15.4 MB solo con los vectores (sin contar los índices ni otras columnas).

1. Extensión y tabla

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id bigserial PRIMARY KEY,
  content text NOT NULL,
  embedding vector(384) NOT NULL
);

2. Índice

CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

3. Búsqueda

PREPARE recibe como $1 el vector de la pregunta obtenido de /api/embed. Pásalo como texto, por ejemplo '[0.01,-0.02,…]'.

PREPARE search_documents(vector(384)) AS
  SELECT id, content, 1 - (embedding <=> $1) AS cosine_similarity
  FROM documents
  ORDER BY embedding <=> $1
  LIMIT 5;

-- EXECUTE search_documents('[0.0123, -0.0456, ...]');

Límites y valores por defecto del README y el código fuente de pgvector 0.8.7, comprobados el 2026-11-04. README de pgvector

Todo se hace en tu navegador. La herramienta escribe SQL y no se conecta a nada.

Por qué una búsqueda puede devolver menos filas que LIMIT

Con HNSW, los resultados están limitados por hnsw.ef_search, que es 40 por defecto. En una tabla de prueba de 200 filas con un índice HNSW, LIMIT 100 devolvió 40 filas; tras SET hnsw.ef_search = 100, devolvió 100. Fija hnsw.ef_search en al menos el número de filas que necesitas. SET LOCAL dentro de una transacción lo cambia solo para una consulta.

El filtrado también reduce los resultados. Con los índices aproximados, el README dice que la condición WHERE se aplica después de recorrer el índice: si una condición coincide con el 10% de las filas, HNSW con el hnsw.ef_search predeterminado de 40 devuelve solo 4 filas de media. Desde la versión 0.8.0, los escaneos iterativos del índice (SET hnsw.iterative_scan = strict_order, o relaxed_order) recorren más parte del índice hasta encontrar suficientes filas, hasta hnsw.max_scan_tuples (20,000 por defecto).

El README añade que los vectores NULL no se indexan, ni tampoco los vectores cero para la distancia del coseno.

Pasar los resultados a /api/chat con fuentes numeradas

Cuando la búsqueda devuelve las filas más cercanas, el modelo las necesita en su prompt. /api/chat de Ollama recibe una lista de mensajes, cada uno con un rol (system, user, assistant o tool) y su content. Una disposición sencilla es un mensaje system con las reglas y un mensaje user que enumera las filas con un número cada una, como [1] y [2], seguido de la pregunta. Con números, la respuesta puede decir qué filas ha usado, y puedes asociar cada número a su fila.

Para obtener las citas con una forma fija, da al campo format un JSON Schema con la respuesta y una lista de citas, cada una con el número de la fuente y un fragmento citado corto. La página de salidas estructuradas de Ollama recomienda escribir también el esquema en el prompt y bajar la temperatura (temperature), por ejemplo a 0, para obtener una salida más determinista. En psql, la función json_agg reúne las filas de una consulta en un array JSON, que la herramienta de abajo lee directamente.

Pruébalo: RAG Prompt Builder

Las fuentes del ejemplo están escritas a mano con la forma que devuelve json_agg.

3 fuentes, leídas como un array JSON de objetos.

Comando curl

Envía la petición a Ollama en esta máquina (localhost:11434).

curl http://localhost:11434/api/chat -d '{
  "model": "qwen2.5:7b",
  "messages": [
    {
      "role": "system",
      "content": "Answer the question using only the numbered sources in the user message. Cite the number of each source you use. If the sources do not contain the answer, say that you do not know and cite nothing. Answer in the same language as the question. For each citation, give the source number and copy a short quote from that source exactly as it is written. Return JSON that matches this schema: {\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"integer\"},\"quote\":{\"type\":\"string\"}},\"required\":[\"source\",\"quote\"]}}},\"required\":[\"answer\",\"citations\"]}"
    },
    {
      "role": "user",
      "content": "Sources:\n\n[1] (id: 12)\nWith HNSW, hnsw.ef_search is 40 by default, so a query returns at most 40 rows unless you raise it.\n\n[2] (id: 7)\nSET LOCAL inside a transaction changes hnsw.ef_search for a single query.\n\n[3] (id: 3)\npgvector supports PostgreSQL 13 and later.\n\nQuestion: Why does my HNSW search return only 40 rows with LIMIT 100?"
    }
  ],
  "stream": false,
  "format": {
    "type": "object",
    "properties": {
      "answer": {
        "type": "string"
      },
      "citations": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "source": {
              "type": "integer"
            },
            "quote": {
              "type": "string"
            }
          },
          "required": [
            "source",
            "quote"
          ]
        }
      }
    },
    "required": [
      "answer",
      "citations"
    ]
  },
  "options": {
    "temperature": 0
  },
  "truncate": false
}'

Cuerpo de la petición (JSON)

{
  "model": "qwen2.5:7b",
  "messages": [
    {
      "role": "system",
      "content": "Answer the question using only the numbered sources in the user message. Cite the number of each source you use. If the sources do not contain the answer, say that you do not know and cite nothing. Answer in the same language as the question. For each citation, give the source number and copy a short quote from that source exactly as it is written. Return JSON that matches this schema: {\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"integer\"},\"quote\":{\"type\":\"string\"}},\"required\":[\"source\",\"quote\"]}}},\"required\":[\"answer\",\"citations\"]}"
    },
    {
      "role": "user",
      "content": "Sources:\n\n[1] (id: 12)\nWith HNSW, hnsw.ef_search is 40 by default, so a query returns at most 40 rows unless you raise it.\n\n[2] (id: 7)\nSET LOCAL inside a transaction changes hnsw.ef_search for a single query.\n\n[3] (id: 3)\npgvector supports PostgreSQL 13 and later.\n\nQuestion: Why does my HNSW search return only 40 rows with LIMIT 100?"
    }
  ],
  "stream": false,
  "format": {
    "type": "object",
    "properties": {
      "answer": {
        "type": "string"
      },
      "citations": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "source": {
              "type": "integer"
            },
            "quote": {
              "type": "string"
            }
          },
          "required": [
            "source",
            "quote"
          ]
        }
      }
    },
    "required": [
      "answer",
      "citations"
    ]
  },
  "options": {
    "temperature": 0
  },
  "truncate": false
}

En la respuesta, el texto está en message.content. Si prompt_eval_count es mucho menor de lo que esperas, es posible que el prompt se haya recortado.

Campos de la petición según la referencia de la API y el código fuente de Ollama, comprobados en Ollama 0.40.11 el 2026-11-05. Ollama API: /api/chat

Todo se ejecuta en tu navegador. La herramienta escribe la petición y no se conecta a nada.

Cuando las fuentes no caben, Ollama recorta el principio del prompt

Las reglas, las filas y la pregunta tienen que caber en la longitud de contexto. La página de longitud de contexto de Ollama indica un valor por defecto de 4k tokens con menos de 24 GiB de VRAM, y sus preguntas frecuentes indican 4096 tokens; ollama ps en nuestra máquina de prueba mostró 4096. num_ctx en options o la variable de entorno OLLAMA_CONTEXT_LENGTH lo cambian.

Cuando el prompt es más largo, /api/chat no devuelve un error por defecto. En el código fuente de Ollama (0.40.11), el servidor primero descarta mensajes anteriores completos, conservando los mensajes system y el último mensaje. Si el prompt restante sigue siendo demasiado largo, conserva los primeros tokens (num_keep, 4 por defecto) y el final del prompt, y descarta la parte intermedia. En nuestra prueba con qwen2.5:7b y num_ctx 1024, un prompt de 2,068 tokens se recortó a 514. Se perdieron el mensaje system y las primeras fuentes: con la respuesta en la fuente [1], el modelo contestó con una palabra en clave que no estaba en ninguna fuente, y con la respuesta en la última fuente, contestó correctamente pero sin el número de fuente que pedía el mensaje system. La respuesta no contenía ningún error; el registro del servidor tenía el aviso "truncating input prompt".

El código fuente de Ollama también tiene un campo truncate para las peticiones de chat, que no figura en la página de /api/chat. Con "truncate": false, la misma petición devolvió HTTP 400 con el error "request (2068 tokens) exceeds the available context size (1024 tokens), try increasing it". El campo prompt_eval_count de una respuesta es el número de tokens del prompt; en la prueba con recorte fue 514, no 2,068.

Como se conserva el final del prompt, pon la pregunta después de las fuentes. Para saber cuándo las fuentes no caben, envía "truncate": false y después sube num_ctx o envía menos filas. Un num_ctx mayor necesita más memoria.

Comprobar las citas

Una cita se puede comprobar de forma mecánica: el número debe corresponder a una fuente que se envió, y el fragmento citado debería aparecer en esa fuente. En nuestra prueba con cuatro fuentes y qwen2.5:7b, los fragmentos citados de las respuestas en JSON coincidían palabra por palabra con las fuentes. Eso no demuestra que la respuesta sea correcta: al preguntar por el tamaño de un vector de 384 dimensiones, el modelo citó la regla correcta (4 veces las dimensiones más 8 bytes) y aun así respondió 1552 bytes en lugar de 1,544.

Mira también las respuestas sin citas. Cuando las fuentes no contenían la respuesta, el modelo una vez contestó con su propio conocimiento y sin citas, y otra vez dijo que no lo sabía pero citó igualmente una fuente sin relación. Si done_reason en la respuesta es "length", la respuesta se cortó por num_predict y puede que le falten las citas.

El comprobador de abajo solo compara texto. Si una fuente respalda de verdad una frase lo tiene que juzgar una persona.

Pruébalo: RAG Citation Checker

La respuesta del ejemplo está escrita a mano para mostrar un fragmento citado que no está en su fuente.

4 fuentes (un array JSON de cadenas). Respuesta leída como una respuesta de /api/chat de Ollama, en forma de JSON con citas.

Tokens del prompt (prompt_eval_count): 306

  • El fragmento citado de la cita #2 no está en la fuente [4].

Citas

FuenteFragmento citadoResultado
[2]hnsw.ef_search is 40 by defaultEncontrado tal cual
[4]4 bytes per dimension plus 8No está en esta fuente

Esto solo comprueba que los números y los fragmentos citados coinciden con las fuentes. No puede decir si la respuesta es correcta ni si una fuente respalda de verdad una frase.

Formato de la respuesta según la referencia de la API y el código fuente de Ollama, comprobado en Ollama 0.40.11 el 2026-11-05. Ollama API: /api/chat

Todo se ejecuta en tu navegador. La herramienta no se conecta a nada.

Memoria en un servidor pequeño

El README dice que un índice HNSW se construye mucho más rápido cuando el grafo cabe en maintenance_work_mem, muestra un aviso cuando ya no cabe y advierte de que no se fije maintenance_work_mem tan alto que agote la memoria del servidor. Como con otros índices, es más rápido crear el índice después de cargar los datos iniciales. Los índices no tienen que caber en memoria, pero probablemente verás mejor rendimiento si caben; SELECT pg_size_pretty(pg_relation_size('index_name')) muestra el tamaño de un índice.

Para un conjunto de trabajo más pequeño y índices más pequeños, el README sugiere halfvec, la indexación de media precisión o la cuantización binaria. Si Ollama se ejecuta en el mismo servidor, PostgreSQL y los modelos comparten la misma memoria; nuestro artículo sobre cómo vigilar la memoria de Ollama explica qué determina cuánta usan los modelos.

Fuentes

Sigue leyendo