Base de datos vectorial en un VPS: pgvector o Qdrant
Compara pgvector, Qdrant, Chroma y fuerza bruta en un VPS. La app y el índice comparten máquina: calcula RAM, coste de indexado y latencia real.
El coste real de una base de datos vectorial en un VPS
Ejecutar una base de datos vectorial en un VPS (servidor privado virtual) elimina el problema para el que los proveedores gestionados venden una solución. La aplicación y el índice están en la misma máquina, por lo que una solicitud de búsqueda atraviesa un socket de loopback en lugar de la red. Lo que queda es el coste que siempre fue el principal: convertir el texto en vectores. A esto se suman otros dos costes: el tiempo necesario para crear el índice y la RAM que este ocupa mientras está disponible para atender solicitudes.
Esto cambia qué decisiones son importantes. La región y el tiempo de ida y vuelta hasta el endpoint dejan de ser una preocupación. En cambio, importan el producto del número de vectores, las dimensiones y cuatro bytes, porque determina si el índice cabe en la memoria que alquila cada mes.
Dónde se van realmente los milisegundos en un solo equipo
Siga una consulta de similitud a través de una pila autohospedada.
- Un modelo de embeddings convierte el texto de la consulta en un vector. En la CPU, esto tarda de decenas a cientos de milisegundos para una cadena corta. En una GPU, tarda unos pocos milisegundos.
- El vector se envía al almacén. A través de TCP de loopback o de un socket de dominio Unix, esto tarda una fracción de milisegundo.
- El almacén recorre su índice y devuelve las filas más cercanas.
- El código lee el texto coincidente y construye un prompt.
El paso 1 suele ser el valor más alto de esa lista. El paso 2 es en el que compiten los proveedores alojados y, en un solo equipo, apenas existe. No suponga cómo se distribuye el tiempo. Mida ambos extremos en su propio servidor.
curl http://127.0.0.1:11434/api/embed -s -o /dev/null \
-w 'embed: %{time_total}s\n' \
-d '{"model": "nomic-embed-text", "input": "how do I rotate the api key"}'A continuación, ejecute \timing on en psql antes de la consulta de búsqueda. Si el primer comando muestra embed: 0.184312s y psql responde Time: 4.201 ms, ajustar el índice es la tarea equivocada: la latencia la provoca el modelo de embeddings. Ejecutar el modelo de embeddings localmente con Ollama coloca el paso 1 en la misma CPU que los pasos 2 y 3, por lo que ambas partes compiten por los mismos núcleos y la misma RAM. El ciclo de ingesta y recuperación que rodea este almacén se describe en la guía de la canalización RAG autohospedada. RAG significa generación aumentada por recuperación: busca en sus propios documentos y pega las coincidencias más relevantes en un prompt.
Por debajo de unos cien mil vectores, analícelos todos
Un análisis exhaustivo compara la consulta con todos los vectores almacenados. La recuperación es perfecta por definición. No necesita un índice ni una fase de compilación, y no puede quedar desactualizado respecto de los datos.
La aritmética indica cuándo deja de ser adecuado. Un análisis lee n * d * 4 bytes por consulta, donde n es el número de vectores y d es la dimensión. Con 100,000 vectores de 768 dimensiones, son 307 MB por consulta, una cantidad que una CPU moderna procesa en unas pocas decenas de milisegundos. Con 5 millones de vectores, son 15 GB por consulta. Eso ya no es una consulta viable.
Por tanto, almacene los vectores en SQLite y haga la comparación en NumPy.
import sqlite3, numpy as np
db = sqlite3.connect("docs.db")
db.execute("CREATE TABLE IF NOT EXISTS docs (id INTEGER PRIMARY KEY, body TEXT, vec BLOB)")
def add(body, vec):
v = np.asarray(vec, dtype=np.float32)
v /= np.linalg.norm(v)
db.execute("INSERT INTO docs (body, vec) VALUES (?, ?)", (body, v.tobytes()))
db.commit()
def search(query_vec, k=5):
rows = db.execute("SELECT id, body, vec FROM docs").fetchall()
mat = np.frombuffer(b"".join(r[2] for r in rows), dtype=np.float32).reshape(len(rows), -1)
q = np.asarray(query_vec, dtype=np.float32)
q /= np.linalg.norm(q)
scores = mat @ q
return [(rows[i][0], rows[i][1], float(scores[i])) for i in np.argsort(-scores)[:k]]Ambos lados se escalan a una longitud unitaria, por lo que el producto escalar es la similitud coseno y una puntuación más alta indica una coincidencia más cercana. Cargue mat una sola vez al iniciar, en lugar de una vez por consulta, y la lectura de SQLite deja por completo la ruta crítica.
Mídalo en su propio equipo antes de descartarlo.
import time
t = time.perf_counter()
search(q)
print(f"{(time.perf_counter() - t) * 1000:.1f} ms")Los límites reales son estos: un único proceso mantiene toda la matriz en RAM, y no ofrece filtrado por metadatos ni un modelo para escritores concurrentes. Cuando uno de esos puntos sea la causa del problema, cambie de solución. SQLite es un almacén serio para el servidor, como se explica en la guía de SQLite en producción, y si su carga de trabajo real consiste en analizar columnas en lugar de servir filas, la comparación entre DuckDB y SQLite resulta más útil.
pgvector si ya ejecuta Postgres
Si la aplicación ya tiene una base de datos Postgres, pgvector añade la menor superficie nueva. Es una extensión, no un servicio. Los vectores se almacenan en una tabla normal junto a la fila que describen, por lo que una búsqueda filtrada es una cláusula WHERE en lugar de un segundo sistema que haya que mantener sincronizado.
Ubuntu 24.04 lo incluye en el componente universe.
sudo apt update
sudo apt install -y postgresql-16-pgvector
sudo -u postgres psql -d yourdb -c 'CREATE EXTENSION vector;'Ese paquete es pgvector 0.6.0 en agosto de 2026, una versión bastante anterior a la disponible en upstream. En particular, las exploraciones iterativas de índices necesitan la versión 0.8, así que debe obtenerla del repositorio propio del proyecto PostgreSQL.
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17-pgvectorSustituya 17 por la versión principal de su servidor, que muestra sudo -u postgres psql -tAc 'SHOW server_version'. Si instala el paquete de extensión compilado para una versión principal incorrecta, CREATE EXTENSION falla porque Postgres sólo busca en el directorio share de la versión que está ejecutándose:
ERROR: could not open extension control file "/usr/share/postgresql/16/extension/vector.control": No such file or directoryEl esquema es SQL normal con un tipo nuevo.
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id bigint NOT NULL,
body text NOT NULL,
embedding vector(768)
);
SELECT id, body FROM chunks
ORDER BY embedding <=> '[0.013, -0.021, 0.004]'
LIMIT 5;<=> es la distancia coseno, <-> es la distancia L2 (euclídea) y <#> es el producto interno negativo. Use la opción para la que se entrenó el modelo de embeddings. Si elige la incorrecta, no se produce ningún error, pero los resultados son discretamente peores.
Sin un índice, esa consulta es una búsqueda exacta en todas las filas. Es la versión de Postgres de la búsqueda por fuerza bruta anterior y tiene el mismo recall perfecto. Aumentar max_parallel_workers_per_gather asigna más núcleos a la operación. Hágalo primero y cree el índice después, porque así tendrá una referencia de recall con la que comparar el índice.
Qdrant, cuando el índice supera la capacidad de la base de datos
Qdrant es un almacén vectorial especializado escrito en Rust. Resulta adecuado cuando el índice es lo bastante grande como para que prefiera que su construcción no compita con el Postgres de la aplicación, o cuando necesita filtrado de payload y cuantización que pgvector no ofrece.
docker run -d --name qdrant \
-p 127.0.0.1:6333:6333 -p 127.0.0.1:6334:6334 \
-e QDRANT__SERVICE__API_KEY="$(openssl rand -hex 32)" \
-v "$(pwd)/qdrant_storage:/qdrant/storage:z" \
qdrant/qdrantEl puerto 6333 proporciona la API REST (transferencia de estado representacional) y un panel en /dashboard, mientras que el puerto 6334 proporciona gRPC. En un VPS público, hay dos aspectos importantes. La documentación de Qdrant indica que el servicio se ejecuta de forma predeterminada «sin cifrado ni autenticación», y -p 6333:6333 del inicio rápido se enlaza a todas las interfaces. Docker publica ese puerto más allá de una regla de ufw porque escribe sus propias reglas de reenvío. Enlace el servicio a 127.0.0.1 y configure una clave de API. Una instancia de Qdrant accesible desde una IP pública y sin clave expone públicamente una copia de sus documentos.
curl -s http://127.0.0.1:6333/collections -H "api-key: $QDRANT_API_KEY"Una respuesta correcta tiene el formato {"result":{"collections":[]},"status":"ok","time":0.00002}. Si recibe {"status":{"error":"Unauthorized"}}, el nombre de la cabecera o la clave son incorrectos. Si no recibe ninguna respuesta, el contenedor no se está ejecutando o está enlazado en otra dirección. Determinar si ese contenedor tiene la configuración adecuada para su equipo es la cuestión habitual de cualquier servicio con estado, por lo que la comparación entre Docker y una base de datos en el host se aplica aquí sin cambios.
Chroma y para qué sirve
Chroma es la forma más rápida de pasar de cero a una demostración funcional de recuperación.
pip install chromadb
chroma run --path /srv/chromaEsto escucha en el puerto 8000, y chromadb.HttpClient(host="localhost", port=8000) se conecta a él. Chroma incluye una función de embeddings predeterminada, por lo que un primer prototipo no necesita ningún servidor de modelos independiente.
Sea claro con la contrapartida. Chroma resulta cómodo porque oculta las decisiones que aborda esta guía: qué métrica de distancia usar y cuánta RAM ocupará el resultado. Esto es correcto para un prototipo, pero no para el sistema del que tendrá que ocuparse cuando falle. Si sus datos ya están en Postgres, moverlos a Chroma añade un proceso y un problema de sincronización para resolver un problema que pgvector no tiene.
Cuánta RAM necesita el índice
Parta de los vectores sin comprimir, porque establecen el mínimo y ningún ajuste puede reducirlo.
bytes = number_of_vectors * dimensions * 4Cuatro bytes corresponden a un valor float de 32 bits por dimensión. La documentación de planificación de capacidad de Qdrant añade un factor de 1.5 para los metadatos y los segmentos temporales que se crean durante la optimización:
memory_size = number_of_vectors * vector_dimension * 4 bytes * 1.5Esta es la fórmula aplicada a un millón de vectores, con las dimensiones que producen los modelos de embeddings habituales.
The data behind this chart
[
{
"label": "384 dims",
"raw_gib": 1.43,
"with_overhead_gib": 2.15
},
{
"label": "768 dims",
"raw_gib": 2.86,
"with_overhead_gib": 4.29
},
{
"label": "1024 dims",
"raw_gib": 3.81,
"with_overhead_gib": 5.72
},
{
"label": "1536 dims",
"raw_gib": 5.72,
"with_overhead_gib": 8.58
},
{
"label": "3072 dims",
"raw_gib": 11.44,
"with_overhead_gib": 17.17
}
]Esos valores son el resultado de la fórmula en gibibytes (GiB), no una medición. Interprételos como el tamaño del espacio que debe reservar en memoria. Un modelo de 768 dimensiones, como nomic-embed-text, para un millón de fragmentos necesita aproximadamente 4.29 GiB, lo que cabe en un plan de 8 GB y deja espacio para Postgres. El mismo corpus con 3072 dimensiones necesita 17.17 GiB y no cabe.
La variable decisiva es la primera columna de esa tabla, no la última. Reducir a la mitad la dimensión reduce a la mitad todos los bytes posteriores de forma permanente. Un modelo de 768 dimensiones que obtiene una puntuación algo peor en una clasificación pública suele ser una opción de ingeniería más adecuada en un VPS. El tipo halfvec de pgvector almacena después valores float de 16 bits, lo que vuelve a reducir a la mitad los bytes, y se indexa mediante una expresión:
CREATE INDEX ON chunks USING hnsw ((embedding::halfvec(768)) halfvec_cosine_ops);Debe conocer un límite antes de elegir el modelo. El tipo vector de pgvector admite hasta 16,000 dimensiones, pero sus índices HNSW e IVFFlat sólo cubren 2,000. Por encima de ese valor, debe indexar una conversión a halfvec, que admite hasta 4,000, o no crear ningún índice.
Coste de m y ef_construction durante la compilación
HNSW (hierarchical navigable small world) es el índice que usan pgvector y Qdrant. Es un grafo por capas. Cada vector es un nodo con enlaces a nodos cercanos. La búsqueda avanza por esos enlaces hacia la consulta en lugar de leerlo todo.
m indica cuántos enlaces conserva cada nodo. La documentación de Faiss calcula la memoria de HNSW como (d * 4 + m * 2 * 4) bytes por vector y recomienda mantener m entre 4 y 64. Ejecute ese cálculo con 768 dimensiones y un millón de vectores.
The data behind this chart
[
{
"label": "m = 8",
"link_bytes_per_vector": 64,
"total_gib": 2.92
},
{
"label": "m = 16 (default)",
"link_bytes_per_vector": 128,
"total_gib": 2.98
},
{
"label": "m = 32",
"link_bytes_per_vector": 256,
"total_gib": 3.1
},
{
"label": "m = 64",
"link_bytes_per_vector": 512,
"total_gib": 3.34
}
]La diferencia de tamaño resulta significativa. Pasar del valor predeterminado m = 16 a m = 64 añade 512 bytes de enlaces por vector, frente a los 3072 bytes de los datos del vector. Por tanto, el total pasa de 2.98 GiB a 3.34 GiB. Es aproximadamente un 12 por ciento. Con estas dimensiones, m no es lo que determina el consumo de memoria. Los vectores son el factor principal.
El coste real de m se refleja en el tiempo de compilación y de inserción, porque colocar un nodo implica encontrar y enlazar esa cantidad de vecinos. ef_construction es el tamaño de la lista de candidatos que el compilador considera al colocar cada nodo. Aumentarlo produce un grafo mejor y una compilación más lenta. No cambia en absoluto el tamaño final del índice.
SET maintenance_work_mem = '4GB';
SET max_parallel_maintenance_workers = 7;
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);maintenance_work_mem es la configuración que determina si la compilación tarda minutos u horas, porque pgvector construye el grafo en memoria cuando hay espacio suficiente. Cuando deja de caber, pgvector lo indica:
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL: Building will take significantly more time.
HINT: Increase maintenance_work_mem to speed up builds.Ese aviso es la línea más útil que imprime pgvector. Indica que la compilación ha pasado a una ruta mucho más lenta. Cancele el proceso, aumente la configuración por encima de la cantidad de RAM calculada antes y vuelva a iniciarlo. Supervise el progreso desde otra sesión:
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS pct
FROM pg_stat_progress_create_index;HNSW informa de initializing y después de loading tuples. Si la compilación permanece durante mucho tiempo en un porcentaje bajo mientras el disco no está ocupado, el problema es maintenance_work_mem, no una consulta bloqueada.
Hay dos aspectos que conviene tener en cuenta. El README de pgvector indica que HNSW «tiene tiempos de compilación más lentos y usa más memoria» que IVFFlat, a cambio de una mejor relación entre velocidad y recall. Además, HNSW puede crearse sobre una tabla vacía, mientras que IVFFlat debe ejecutar k-means sobre datos representativos antes. Por eso, crear IVFFlat sobre una tabla vacía produce un recall deficiente. En un esquema nuevo, HNSW es el índice que puede crear de antemano.
ef_search: el parámetro que se ajusta después de crear el índice
m y ef_construction quedan fijados en el índice. ef_search no. Define cuántos candidatos conserva la búsqueda mientras recorre el grafo, y puede cambiarse por sesión o por consulta.
SET hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 5;El valor predeterminado es 40. Si lo aumenta, aumenta el recall y también la latencia. Si lo reduce, ambos disminuyen. Este es el único control del recall que puede cambiarse sin volver a crear el índice. Por eso, ajústelo con un conjunto fijo de consultas cuyas respuestas correctas ya conozca y deténgase cuando el recall deje de mejorar.
Hay una limitación importante. ef_search interactúa mal con una cláusula WHERE selectiva, porque el índice devuelve un número fijo de candidatos y el filtro se aplica después. Un filtro que rechaza la mayoría de las filas puede dejar menos de LIMIT resultados, aunque haya filas coincidentes en la tabla. pgvector 0.8 resuelve esto con búsquedas iterativas:
SET hnsw.iterative_scan = relaxed_order;A continuación, el índice vuelve a buscar candidatos hasta satisfacer el límite, con un máximo de hnsw.max_scan_tuples, cuyo valor predeterminado es 20000. strict_order conserva el orden exacto por distancia y consume más recursos. Esta función no está disponible en el paquete de Ubuntu 0.6.0. La presencia de menos filas de las esperadas al aplicar un filtro permite detectar su ausencia.
Por qué el índice debe caber en la RAM
Una búsqueda HNSW recorre un grafo. Cada salto lee un nodo almacenado en una ubicación independiente de la anterior, por lo que el patrón de acceso es casi aleatorio y la lectura anticipada no ayuda. Mientras el grafo está en la RAM, cada salto es una referencia de memoria. Cuando deja de estarlo, un salto puede convertirse en una lectura de disco, y una búsqueda que accede a unos cientos de nodos puede generar unos cientos de lecturas.
La documentación de Qdrant lo resume así: «si almacena la mitad de vectores en la RAM, la latencia de búsqueda aproximadamente se duplicará». Planifique teniendo en cuenta esta afirmación.
Cuando el índice realmente no cabe, cada opción implica una compensación que debe elegir de forma deliberada.
- Asigne los vectores mediante memory mapping para que el sistema operativo almacene en caché las páginas de acceso frecuente y deje las páginas menos usadas en el disco. Para que esto sea tolerable, necesita almacenamiento NVMe (non-volatile memory express) rápido.
- Cuantice los vectores y almacene cada dimensión en un byte en lugar de cuatro. Esto reduce cuatro veces el tamaño de los vectores, con un coste de recall pequeño y medible.
- Convierta los vectores a
halfvecen pgvector. Esto reduce a la mitad el tamaño, con una pérdida de recall menor que la cuantización a un byte. - Genere embeddings con un modelo más pequeño. Es la solución más barata y la que se suele omitir porque requiere volver a generar los embeddings del corpus.
Modos de fallo y textos que verá
could not open extension control file. El paquete pgvector correspondiente a la versión principal de Postgres en ejecución no está instalado. Muestre la versión con sudo -u postgres psql -tAc 'SHOW server_version' e instale el postgresql-NN-pgvector correspondiente.
ERROR: expected 768 dimensions, not 1536. El tipo de columna y el modelo no coinciden. Cambió los modelos de embeddings y no volvió a generar los embeddings. No hay una solución parcial, porque los vectores de dos modelos distintos no son comparables, por lo que debe regenerar todas las filas.
La consulta es lenta y EXPLAIN muestra un escaneo secuencial. La clase de operador del índice y el operador de la consulta no coinciden. vector_cosine_ops sólo sirve para <=>. Ejecute EXPLAIN ANALYZE sobre la consulta y busque Index Scan using ... on chunks. Si aparece Seq Scan on chunks, reconstruya el índice con la clase de operador que coincida con el operador que realmente utiliza en las consultas.
Hay menos filas que LIMIT y está presente una cláusula WHERE. Ese es el problema de filtrado descrito arriba. Aumente hnsw.ef_search o cambie a pgvector 0.8 y establezca hnsw.iterative_scan.
La creación del índice termina porque el proceso desaparece y no hay ningún error en psql. Si maintenance_work_mem está establecido en la mayor parte de la memoria de la máquina, mientras shared_buffers y la aplicación también necesitan memoria, el kernel termina activando el asesino de procesos por falta de memoria. sudo dmesg -T | grep -i 'killed process' muestra la línea que identifica postgres. Reduzca el valor o cree el índice en un plan con más recursos y restaure el dump.
Elegir una opción
Si ya ejecuta Postgres y tiene menos de unos pocos millones de vectores, use pgvector. El índice se encuentra junto a los datos, el filtrado es una cláusula WHERE y las copias de seguridad existentes ya lo incluyen. Si el índice es lo bastante grande como para necesitar su propio límite de memoria, o necesita aplicar un filtrado intensivo sobre los datos asociados, ejecute Qdrant junto a Postgres y acepte tener que administrar un segundo servicio.
Por debajo de aproximadamente cien mil vectores, mida el escaneo por fuerza bruta antes de instalar nada. Una búsqueda exhaustiva con recuperación perfecta y sin fase de construcción no es una solución provisional para ese tamaño. Es la opción correcta. Elegir en su lugar un índice aproximado implica asumir tareas de ajuste y presión sobre la RAM a cambio de unos milisegundos que no estaba consumiendo.
FAQ
¿Necesito una base de datos vectorial dedicada o basta con Postgres?
Si los datos ya están en Postgres, pgvector es suficiente durante mucho más tiempo de lo que sugieren la mayoría de las comparativas. Almacena los vectores en una columna normal, por lo que una búsqueda filtrada es una cláusula WHERE y las copias de seguridad existentes también cubren el índice. Cambie a un almacén dedicado como Qdrant cuando la carga vectorial necesite su propio límite de memoria o cuando necesite filtrado de payload y cuantización que pgvector no proporciona.
¿Cuántos vectores puede alojar un VPS?
Calculelo en lugar de hacer una estimación, mediante number_of_vectors * dimensions * 4 bytes * 1.5. Un millón de vectores de 768 dimensiones ocupa aproximadamente 4.3 GiB, por lo que un plan de 8 GB puede alojarlos y dejar memoria disponible para Postgres. Un millón de vectores de 3072 dimensiones ocupa aproximadamente 17 GiB y necesita un plan mucho mayor. El factor que más influye es la dimensión del modelo de embeddings, así que elija el modelo teniendo en cuenta el coste de memoria.
¿Por qué la búsqueda vectorial es lenta cuando el índice está en la misma máquina?
En un solo equipo, la red no es el problema. Revise estos dos factores. Primero, mida por separado el tiempo de la llamada de embeddings, porque generar el vector de consulta en la CPU suele tardar mucho más que la búsqueda. Segundo, compruebe que el índice esté en la RAM. Una búsqueda HNSW recorre aleatoriamente un grafo, por lo que, cuando el grafo pasa a disco, cada salto puede convertirse en una lectura de disco. La documentación de Qdrant indica que reducir a la mitad los vectores mantenidos en la RAM aumenta aproximadamente al doble la latencia de búsqueda.
¿Debo crear un índice HNSW?
No si tiene menos de aproximadamente cien mil vectores. Un escaneo exhaustivo lee n * d * 4 bytes por consulta, es decir, 307 MB con 100,000 vectores de 768 dimensiones. Una CPU moderna puede procesarlos en decenas de milisegundos, con recall perfecto y sin una fase de creación del índice. Mida primero el escaneo en su propio hardware. Cree el índice cuando el tiempo medido del escaneo sea realmente demasiado lento, no porque lo recomiende un artículo comparativo.
¿Qué coste real tiene aumentar m?
Aumenta mucho más el tiempo de creación y de inserción que la memoria. Con 768 dimensiones, pasar del valor predeterminado m = 16 a m = 64 añade 512 bytes de enlaces del grafo por vector, frente a 3072 bytes de datos vectoriales, por lo que la memoria total aumenta aproximadamente un 12 por ciento. Sin embargo, cada inserción debe encontrar y enlazar cuatro veces más vecinos. Ajuste ef_search primero, porque puede cambiarlo sin coste y no requiere reconstruir el índice.