~/ / 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étodo | Qué dice el README |
|---|---|
| Compilar desde el código fuente | git clone --branch v0.8.7, y después make y make install (puede hacer falta sudo) |
| Docker | La 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 |
| Homebrew | brew 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.
| Dimensiones | vector | halfvec |
|---|---|---|
| 384 | 1,544 bytes | 776 bytes |
| 768 | 3,080 bytes | 1,544 bytes |
| 1024 | 4,104 bytes | 2,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.
| Operador | Devuelve | Clase 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 L1 | vector_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.
| Punto | HNSW | IVFFlat |
|---|---|---|
| Cómo funciona | Un grafo de varias capas | Divide los vectores en listas y busca en las listas más cercanas a la consulta |
| En comparación con el otro | Mejor equilibrio entre velocidad y exhaustividad, pero tarda más en construirse y usa más memoria | Se construye más rápido y usa menos memoria, pero con un peor equilibrio entre velocidad y exhaustividad |
| Cuándo crearlo | Se puede crear en una tabla vacía (sin paso de entrenamiento) | Cuando la tabla ya tiene algunos datos |
| Opciones de construcción | m (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úsqueda | hnsw.ef_search, 40 por defecto | ivfflat.probes, 1 por defecto; empieza con sqrt(lists) |
| Dimensiones | vector hasta 2,000, halfvec hasta 4,000 | vector 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
| Fuente | Fragmento citado | Resultado |
|---|---|---|
| [2] | hnsw.ef_search is 40 by default | Encontrado tal cual |
| [4] | 4 bytes per dimension plus 8 | No 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
- pgvector README (v0.8.7)
- pgvector source: HNSW limits (src/hnsw.h)
- pgvector source: IVFFlat limits (src/ivfflat.h)
- Ollama: Embeddings
- Ollama API: Generate embeddings (/api/embed)
- PostgreSQL documentation: Lexical Structure (string constants)
- Ollama API: Generate a chat message (/api/chat)
- Ollama: Structured Outputs
- Ollama: Context length
- Ollama FAQ (context window size)
- Ollama source v0.40.11: server/prompt.go (dropping earlier messages)
- Ollama source v0.40.11: llm/llama_server.go (truncating the prompt)
- Ollama source v0.40.11: api/types.go (truncate, num_keep)
- PostgreSQL documentation: Aggregate Functions (json_agg)
Sigue leyendo
- Embeddings con Ollama: lo básico para búsqueda y RAGCrea embeddings con el /api/embed de Ollama: los modelos y sus tamaños, truncate y dimensions, vectores de longitud unitaria y similitud del coseno, según la documentación oficial.
- Vigilar la memoria de Ollama en un VPS pequeñoMira qué mantiene Ollama en memoria, cuánto tiempo y qué ajustes lo cambian: ollama ps, keep_alive y los límites, según las preguntas frecuentes oficiales.
- Conectar Dify con Ollama en tu propio VPSEjecuta Dify en tu propio VPS y usa Ollama como su modelo: instalación, los campos del plugin, el problema de la dirección en Docker y los embeddings, según la documentación oficial.
- Empieza con un LLM local sin un PC caro: guía para principiantes sobre cómo usar un VPS¿No tienes un PC potente? Alquila un VPS y ejecuta un LLM local pequeño desde el navegador: seis pasos, desde la estimación de memoria hasta una pantalla de chat protegida, con los límites explicados con honestidad.