Cómo montar una canalización RAG en tu propio VPS
Ejecuta RAG en un solo VPS con PostgreSQL, pgvector y Ollama: esquema, tamaño de HNSW, modelo local de embeddings y SQL para verificar la recuperación.
Cómo es una canalización RAG autohospedada
Una canalización RAG (generación aumentada mediante recuperación) tiene cinco etapas: dividir los documentos en fragmentos, generar embeddings para los fragmentos, almacenar los vectores, recuperar los más cercanos para una pregunta y enviar esos fragmentos a un modelo de lenguaje que redacta la respuesta. En el VPS que ya alquila, las cuatro primeras etapas se ejecutan en el servidor. PostgreSQL con la extensión pgvector almacena los vectores, y un modelo pequeño de embeddings servido por Ollama convierte el texto en vectores. Sólo la última etapa tiene que salir del servidor.
Esa separación es la base de esta guía. Dividir los documentos en fragmentos es una tarea sencilla de CPU. La generación de embeddings usa un modelo de 137 millones de parámetros que ocupa unos cientos de megabytes de RAM. El almacenamiento consiste en una tabla de Postgres cuyo tamaño puede calcularse con operaciones aritméticas antes de escribir una sola fila. Para un corpus de cientos de miles de fragmentos, todo eso se ejecuta en un VPS normal. La generación es diferente porque tiene un coste en cada pregunta, de forma indefinida.
Qué partes de una canalización RAG tienen realmente un coste
El tutorial de RAG de extremo a extremo de DigitalOcean utiliza una base de datos vectorial gestionada y un modelo de embeddings alojado. Su sección de costes es cualitativa: almacenar en caché las consultas repetidas, mantener reducido el número de fragmentos recuperados y volver a clasificarlos antes de la generación. Ese consejo es correcto. También omite la opción que cambia el cálculo: ejecutar el modelo de embeddings en el servidor que ya está pagando.
Cuente tokens en lugar de dólares, porque el número de tokens no queda desactualizado cuando cambia una lista de precios. Suponga un corpus de 100,000 fragmentos de 400 tokens cada uno, 10,000 preguntas, 8 fragmentos enviados al modelo por respuesta, una pregunta y un bloque de instrucciones de 100 tokens, y respuestas de 400 tokens.
The data behind this chart
[
{
"label": "Embed the corpus (once)",
"tokens_millions": 40,
"tokens_per_question": "4,000"
},
{
"label": "Embed each question",
"tokens_millions": 0.2,
"tokens_per_question": "20"
},
{
"label": "Generation input",
"tokens_millions": 33,
"tokens_per_question": "3,300"
},
{
"label": "Generation output",
"tokens_millions": 4,
"tokens_per_question": "400"
}
]Incrustar todo el corpus supone 40 millones de tokens, y ocurre una sola vez. Repartido entre esas 10,000 preguntas, son 4,000 tokens por pregunta. Si hace cien mil preguntas, la cifra baja a 400. La generación nunca disminuye. En cada pregunta que responda, siempre costará 3,300 tokens de entrada y 400 tokens de salida.
Por tanto, el coste depende de la etapa que se repite. Ejecute usted la etapa de embeddings, porque sólo paga por ella una vez y el VPS ya está en funcionamiento. Pague por la etapa de generación, porque ahí un modelo mejor justifica un coste real. La caché importa por el mismo motivo: un acierto de caché omite la única etapa cuyo coste nunca se amortiza. La diferencia entre una caché KV y una caché de prompts determina qué mitad puede reutilizar, y un prompt RAG tiene un bloque de instrucciones estable seguido de un bloque de fragmentos variable. Esa es la estructura que más se beneficia.
Fragmentación: por qué el tamaño fijo con solapamiento es el valor predeterminado adecuado
Un fragmento es la unidad que se recupera, por lo que su tamaño determina todo lo que ocurre después. Debe ser lo bastante pequeño para que su embedding trate aproximadamente un solo tema. Un embedding es un único punto en el espacio, así que un fragmento que cubre cuatro temas queda entre ellos y cerca de ninguno. También debe ser lo bastante grande para responder por sí solo a una pregunta, porque el modelo de lenguaje ve el fragmento, no el documento que lo rodea.
Empiece con 300 palabras y un solapamiento de 50 palabras. El inglés usa aproximadamente 1.3 tokens por palabra, por lo que 300 palabras son unos 400 tokens. El solapamiento existe porque, de lo contrario, una oración que cae en un límite se divide por la mitad y ninguna de las dos partes responde a la pregunta.
Divida primero según la estructura cuando los documentos la tengan. Separe por encabezados y después por párrafos. Aplique la regla de tamaño fijo sólo dentro de una sección que siga siendo demasiado larga. Un fragmento que empieza en mitad de una oración se lee mal en la respuesta final, porque el modelo cita lo que se le ha proporcionado.
No ajuste la fragmentación antes de poder medirla. El tamaño fijo con solapamiento es determinista y barato de volver a ejecutar, por lo que sirve como línea base que puede superar. Primero cree la consulta de evaluación que aparece más adelante y después cambie una sola cosa cada vez.
Incrustación en el mismo equipo y su coste en RAM y latencia
curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-textnomic-embed-text tiene 137 million parameters y ocupa 274 MB descargado a fecha de August 2026. Compruebe qué devuelve antes de diseñar una tabla basándose en ello.
curl -s http://127.0.0.1:11434/api/embed \
-d '{"model": "nomic-embed-text", "input": "search_document: hello"}' |
python3 -c 'import json,sys; print(len(json.load(sys.stdin)["embeddings"][0]))'Eso imprime 768. El tipo de columna debe coincidir exactamente con ese número.
Dos configuraciones de este modelo suelen causar problemas.
El prefijo de tarea es obligatorio. La ficha del modelo de Nomic indica que la entrada "must include a task instruction prefix". Los documentos se incrustan con search_document: delante y las preguntas con search_query: . Si los omite, no se produce ningún error: recibe los vectores, la calidad de recuperación disminuye y ningún registro indica el motivo.
La entrada larga se trunca silenciosamente. El endpoint /api/embed acepta un campo truncate y su valor predeterminado es true. Además, el modelo distribuido por Ollama anuncia un contexto de 2K. Un fragmento que supera ese límite se corta y se incrusta de todos modos, por lo que su parte final no se podrá buscar. Envíe "truncate": false durante las pruebas para que un fragmento demasiado grande falle en lugar de continuar.
Agrupe las solicitudes y mantenga el modelo residente en memoria.
curl -s http://127.0.0.1:11434/api/embed -d '{
"model": "nomic-embed-text",
"input": ["search_document: first chunk", "search_document: second chunk"],
"keep_alive": "30m"
}' > /dev/nullinput acepta una lista. Una solicitud con 32 fragmentos es más eficiente que 32 solicitudes, porque el viaje de ida y vuelta HTTP y la búsqueda del modelo se realizan una vez en lugar de 32 veces. keep_alive controla cuánto tiempo permanece el modelo en memoria después de una solicitud. El valor predeterminado es de 5 minutos. Cuando vence, la siguiente solicitud vuelve a pagar el tiempo de carga.
Mida los dos valores importantes en su propio equipo. Dependen del número de vCPU, por lo que ninguna cifra publicada coincidirá exactamente.
ollama ps
time curl -s http://127.0.0.1:11434/api/embed \
-d '{"model":"nomic-embed-text","input":"search_document: ... one real chunk ..."}' > /dev/nullollama ps muestra el tamaño residente del modelo cargado. Esa es la RAM que queda comprometida mientras keep_alive lo mantenga en memoria. Divida el valor de salida de time por el tamaño del lote para obtener los segundos por fragmento. Multiplíquelo por el número de fragmentos para calcular el coste total de indexación de una sola ejecución. En un plan que use sólo CPU, espere que un corpus de 100,000 fragmentos tarde horas, no minutos. No hay problema, porque sólo ocurre una vez y puede ejecutarse durante la noche con nice -n 19. Si varias horas no son aceptables, la pregunta real es si alquilar una GPU se amortiza. Eso es un cálculo de equilibrio frente al coste de los tokens de la API, no una cuestión de preferencia.
Si el equipo ya sirve un modelo de chat, el modelo de incrustación será un segundo modelo residente y la RAM se sumará. Ejecutar Ollama en un VPS explica cómo dimensionar la parte de generación, y qué ocurre con un modelo autohospedado cuando hay usuarios simultáneos explica qué sucede cuando varias personas realizan solicitudes al mismo tiempo. El modelo de incrustación es lo bastante pequeño para ejecutarse junto a cualquiera de los dos.
El script de indexación, de principio a fin
En Ubuntu 24.04, un pip install simple fuera de un entorno virtual se detiene con error: externally-managed-environment, porque el Python del sistema pertenece a apt.
python3 -m venv ~/rag
~/rag/bin/pip install "psycopg[binary]" pgvectorimport json, urllib.request
import psycopg
from pgvector.psycopg import register_vector
from pgvector import Vector
OLLAMA = "http://127.0.0.1:11434/api/embed"
MODEL = "nomic-embed-text"
def embed(texts, prefix="search_document: "):
payload = {"model": MODEL,
"input": [prefix + t for t in texts],
"truncate": False,
"keep_alive": "30m"}
req = urllib.request.Request(OLLAMA, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req) as resp:
return json.load(resp)["embeddings"]
def split(text, size=300, overlap=50):
words = text.split()
step = size - overlap
return [" ".join(words[i:i + size]) for i in range(0, len(words), step)]
with psycopg.connect("dbname=rag user=rag") as conn:
register_vector(conn)
for doc_id, text in documents(): # your loader
pieces = split(text)
for start in range(0, len(pieces), 32):
batch = pieces[start:start + 32]
vectors = embed(batch)
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO chunks (doc_id, seq, body, embedding)"
" VALUES (%s, %s, %s, %s)",
[(doc_id, start + i, body, Vector(vec))
for i, (body, vec) in enumerate(zip(batch, vectors))])
conn.commit()documents() es responsabilidad suya: cualquier código que recorra sus archivos o filas y produzca un identificador de documento y su texto. Todo lo demás forma parte de la canalización.
Almacenamiento: el esquema de pgvector y cuánto crece
Ubuntu 24.04 distribuye postgresql-16-pgvector en la versión 0.6.0, que es anterior al tipo halfvec. Use el repositorio del proyecto PostgreSQL para obtener una compilación actual.
sudo apt update && sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17 postgresql-17-pgvectorEl número del nombre del paquete debe coincidir con la versión principal de su servidor. Después, cree el rol, la base de datos y la extensión.
sudo -u postgres createuser --pwprompt rag
sudo -u postgres createdb --owner rag rag
sudo -u postgres psql -d rag -c 'CREATE EXTENSION vector;'CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id text NOT NULL,
seq int NOT NULL,
body text NOT NULL,
embedding vector(768) NOT NULL,
fts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED
);
CREATE INDEX chunks_fts ON chunks USING gin (fts);vector(768) debe coincidir con la salida del modelo. Si inserta en esa columna un vector de dimensión 1024, Postgres lo rechaza con expected 768 dimensions, not 1024, que es el mensaje de error más claro de todo este proceso. La columna generada fts no requiere mantenimiento adicional y permite añadir búsquedas por palabras clave más adelante.
El almacenamiento se calcula mediante aritmética. La documentación de pgvector define un vector con un tamaño de 4 * dimensions + 8 bytes y un halfvec con un tamaño de 2 * dimensions + 8. Las dimensiones indicadas a continuación son el tamaño de salida publicado de cada modelo.
The data behind this chart
[
{
"label": "384 (all-minilm)",
"bytes_per_vector": "1,544",
"vector_mib_per_100k": 147,
"halfvec_mib_per_100k": 74
},
{
"label": "768 (nomic-embed-text)",
"bytes_per_vector": "3,080",
"vector_mib_per_100k": 294,
"halfvec_mib_per_100k": 147
},
{
"label": "1024 (mxbai-embed-large)",
"bytes_per_vector": "4,104",
"vector_mib_per_100k": 391,
"halfvec_mib_per_100k": 196
},
{
"label": "1536 (hosted API model)",
"bytes_per_vector": "6,152",
"vector_mib_per_100k": 587,
"halfvec_mib_per_100k": 294
}
]Con 768 dimensiones, cada vector ocupa 3,080 bytes, por lo que 100,000 fragmentos ocupan 294 MiB de datos vectoriales. El mismo corpus incrustado con un modelo alojado de 1536 dimensiones necesita 587 MiB, y el índice correspondiente crece de forma proporcional. La precisión media reduce ambos valores a la mitad: halfvec(768) almacena ese corpus en 147 MiB. La consulta de puntuación que aparece a continuación permite comprobar en una sola ejecución si esto afecta al recall.
Estas cifras sólo cubren la columna vectorial. El texto, la sobrecarga de las filas y los índices se suman a ese tamaño, así que mida el tamaño real de la tabla.
SELECT pg_size_pretty(pg_total_relation_size('chunks')) AS total,
pg_size_pretty(pg_relation_size('chunks')) AS heap,
count(*) AS n_rows
FROM chunks;Si prefiere la misma extensión con una API y cuentas de usuario, una pila Supabase autohospedada proporciona PostgreSQL con pgvector ya habilitado, y todas las consultas de esta guía funcionan allí sin modificaciones.
Indexación: los ajustes de HNSW importantes
Por debajo de unos pocos miles de filas, omita el índice. La búsqueda exacta lee todas las filas, es suficientemente rápida con ese tamaño y su recall es perfecto. Añada el índice cuando el escaneo secuencial deje de ser suficientemente rápido y tenga en cuenta la compensación: un índice aproximado devuelve vecinos aproximadamente correctos.
SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 3;
CREATE INDEX chunks_embedding ON chunks
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);m = 16 y ef_construction = 64 son los valores predeterminados de pgvector. Aumentarlos mejora el recall, pero aumenta el tiempo de construcción y el tamaño del índice. Use vector_cosine_ops con el operador <=>, salvo que sepa que su modelo genera vectores de longitud unitaria, porque la distancia coseno ignora la longitud del vector y el producto interno no.
Supervise la construcción. Cuando el grafo supera maintenance_work_mem, pgvector lo indica:
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL: Building will take significantly more time.No es un error y la construcción termina, pero pasa a una ruta mucho más lenta. Aumente maintenance_work_mem en la sesión que construye el índice y mantenga sin cambios el valor predeterminado del servidor, porque ese ajuste se aplica a cada operación de mantenimiento y un valor global alto puede agotar la memoria del servidor. Supervise una construcción larga desde una segunda sesión.
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;Después, compare el índice terminado con la memoria disponible en el servidor.
SELECT pg_size_pretty(pg_relation_size('chunks_embedding'));
SHOW shared_buffers;Una búsqueda HNSW recorre un grafo, por lo que accede a páginas dispersas por el índice en lugar de leer un rango. Un índice que no cabe en memoria convierte cada consulta en lecturas de disco, y los usuarios perciben esa latencia alta. Esta es la única regla de dimensionamiento del servidor: el índice y las filas que realmente sirve deben caber en la RAM. free -m y el tamaño anterior son los dos valores que debe comparar.
En el momento de la consulta, hnsw.ef_search es el ajuste del recall y su valor predeterminado es 40.
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 8;
COMMIT;Un valor más alto busca una parte mayor del grafo, encuentra mejores vecinos y aumenta la latencia. Es un ajuste de sesión, por lo que puede aumentarlo para una consulta sin modificar el índice.
Si una consulta no utiliza el índice, el plan lo muestra.
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 8;Un escaneo secuencial en este caso suele deberse al almacenamiento. Un vector de dimensión 768 ocupa 3,080 bytes, más de lo que Postgres mantiene en línea, por lo que el valor pasa a la tabla TOAST (el almacenamiento externo para valores demasiado grandes). La propia nota de pgvector indica que el planificador no incluye el almacenamiento externo en sus estimaciones de coste, lo que puede hacer que un escaneo secuencial parezca más barato de lo que realmente es. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; mantiene los vectores en línea. Se aplica a las filas escritas después del cambio, por lo que las filas existentes necesitan una reescritura de la tabla.
Recuperación: una consulta, dos señales
La búsqueda vectorial encuentra texto con el mismo significado que la pregunta. Es débil con cadenas exactas: un número de pieza, un código de error o un apellido. La búsqueda por palabras clave funciona al contrario, y Postgres ya ofrece esta capacidad. Combínelas en una sola consulta en lugar de ejecutar un segundo sistema.
La fusión de rangos recíprocos es el combinador más sencillo que funciona. Cada resultado obtiene 1 / (60 + rank) de cada lista en la que aparece, y las dos puntuaciones se suman. No necesita normalizar las puntuaciones porque utiliza posiciones en lugar de distancias.
WITH semantic AS (
SELECT id, row_number() OVER (ORDER BY distance) AS rank
FROM (SELECT id, embedding <=> $1 AS distance
FROM chunks ORDER BY embedding <=> $1 LIMIT 40) s
),
keyword AS (
SELECT id, row_number() OVER (ORDER BY score DESC) AS rank
FROM (SELECT c.id, ts_rank_cd(c.fts, q) AS score
FROM chunks c, websearch_to_tsquery('english', $2) q
WHERE c.fts @@ q
ORDER BY score DESC LIMIT 40) k
)
SELECT c.id, c.body,
coalesce(1.0 / (60 + s.rank), 0) + coalesce(1.0 / (60 + k.rank), 0) AS rrf
FROM (SELECT id FROM semantic UNION SELECT id FROM keyword) u
JOIN chunks c ON c.id = u.id
LEFT JOIN semantic s ON s.id = u.id
LEFT JOIN keyword k ON k.id = u.id
ORDER BY rrf DESC
LIMIT 8;$1 es la incrustación de la pregunta generada con el mismo modelo y con el prefijo search_query: . $2 es la pregunta en forma de texto. La aplicación proporciona ambos valores como parámetros. websearch_to_tsquery acepta una pregunta real del usuario sin fallar por la puntuación, mientras que to_tsquery no lo hace. Hay otro aspecto que debe conocer: añadir un filtro WHERE a un recorrido HNSW puede devolver menos filas de las solicitadas, porque primero se busca en el índice y después se aplica el filtro. SET hnsw.iterative_scan = relaxed_order; hace que pgvector siga buscando hasta obtener suficientes filas.
¿Cómo se determina si la recuperación funciona bien?
Este es el paso que casi todas las guías de RAG omiten y el único que indica si las demás decisiones han ayudado. No necesita un framework de evaluación. Necesita 30 preguntas y el id del chunk que responde a cada una.
Escríbalas manualmente. Use preguntas reales sobre este corpus, ejecute cada una, lea el resultado y registre el id del chunk que debería haber quedado primero. Treinta preguntas no resolverán diferencias pequeñas. Detectarán las diferencias importantes, porque esas son grandes.
CREATE TABLE gold (
id bigserial PRIMARY KEY,
question text NOT NULL,
chunk_id bigint NOT NULL REFERENCES chunks(id),
embedding vector(768) NOT NULL
);Genere el embedding de cada pregunta con el prefijo search_query: , almacénelo y puntúe todo el conjunto en una sola consulta.
WITH hits AS (
SELECT g.id,
min(r.rank) FILTER (WHERE r.id = g.chunk_id) AS hit_rank
FROM gold g
CROSS JOIN LATERAL (
SELECT top.id, row_number() OVER (ORDER BY top.distance) AS rank
FROM (SELECT c.id, c.embedding <=> g.embedding AS distance
FROM chunks c
ORDER BY c.embedding <=> g.embedding
LIMIT 10) top
) r
GROUP BY g.id
)
SELECT count(*) AS questions,
count(hit_rank) AS found_in_top_10,
round(avg(coalesce(1.0 / hit_rank, 0)), 3) AS mrr
FROM hits;found_in_top_10 dividido por questions es el recall en 10: la frecuencia con la que la respuesta estaba dentro de la ventana que envía al modelo. MRR (mean reciprocal rank) calcula el promedio de 1 dividido por la posición del chunk correcto y cuenta un fallo como cero. Por tanto, favorece que la respuesta aparezca en primer lugar en vez de en octavo. Ambos valores cambian al modificar el tamaño del chunk, sustituir el modelo de embeddings o añadir búsqueda por palabras clave. Ahora puede ver en qué dirección cambiaron.
Priorice el recall en 10 por encima de todo lo demás, porque el generador no puede usar un chunk que nunca recibió. Cuando el recall en 10 es 0.9 y las respuestas siguen siendo incorrectas, el problema está en el prompt o en el modelo, no en la recuperación. Esta separación evita días de conjeturas.
Compruebe el índice por separado. La búsqueda aproximada reduce el recall, y pgvector muestra cuánto: ejecute la misma consulta con búsqueda exacta y compare los ids.
BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;Nueve ids coincidentes de diez significa que ef_search es correcto. Cuatro de diez significa que debe aumentarlo.
Reranking y generación: dónde una API justifica su coste
Un reranker es un tipo de modelo diferente. Lee la pregunta y un fragmento juntos y puntúa ese par. Esto funciona mejor que comparar dos embeddings calculados de forma independiente. Además, es demasiado lento para ejecutarlo en todo un corpus. Por eso debe usarse en esta fase. Procesa los 40 candidatos devueltos por la recuperación, no los 100,000 fragmentos de la tabla. Así, una API de reranking alojada cobra por 40 pares cortos por pregunta y elimina los falsos positivos menos relevantes antes de que lleguen a la fase costosa.
La generación es el coste recurrente, y hay dos factores que lo controlan. Envíe menos fragmentos. Use recall at 10 para determinar cuántos puede enviar sin perder respuestas. Mantenga la parte inicial del prompt idéntica byte a byte para que el prompt cache del proveedor pueda aprovecharla. Coloque los fragmentos recuperados después de esa parte estable. Almacene también en caché las respuestas terminadas por pregunta. El token generado más barato es el que ya generó la semana pasada.
Dimensionamiento del servidor y cuándo deja de ser suficiente
Cada regla de dimensionamiento de esta sección se basa en mediciones, no en estimaciones.
- La RAM es la limitación principal: el tamaño residente del modelo de
ollama ps, más el tamaño del índice HNSW yshared_buffers, dejando margen para las conexiones y la caché de páginas. - El disco necesita el doble de
pg_total_relation_size('chunks'), porque la reconstrucción de un índice mantiene ambas copias al mismo tiempo. - La CPU determina el tiempo de reindexación: los segundos medidos por bloque multiplicados por el número de bloques.
- La reindexación ocurre con más frecuencia de la prevista, porque cambiar el modelo de embeddings invalida todos los vectores almacenados.
Este diseño deja de ser suficiente cuando se alcanza un límite que puede prever. Cuando el índice HNSW ya no cabe en la RAM que puede comprar, la latencia de las consultas se convierte en búsquedas en disco y ningún ajuste la recupera. Cuando una tabla atiende a muchos tenants y cada consulta filtra por tenant, particionar la tabla se convierte en la solución, y requiere trabajo real. Cuando las escrituras de indexación compiten con las consultas de los usuarios en el mismo servidor, mueva el worker de embeddings a un segundo servidor antes de mover la base de datos. Hasta que ocurra una de esas situaciones, Postgres con pgvector en el VPS que ya alquila es una solución válida para producción, y las cifras anteriores indican a qué distancia está el límite.
FAQ
¿Puedo ejecutar una canalización RAG en un solo VPS o necesito una base de datos vectorial?
Un solo VPS es suficiente para corpus de cientos de miles de fragmentos. Con 768 dimensiones, 100,000 fragmentos ocupan 294 MiB de datos vectoriales, además del texto y del índice HNSW, una cantidad que cabe en la RAM de un plan normal. El límite lo marca la memoria, no el número de filas, porque una búsqueda HNSW salta por distintas partes del índice. La latencia empeora cuando el índice deja de caber en la RAM. Compare pg_relation_size del índice con free -m para saber cuál es su situación.
¿Necesito una GPU para generar embeddings de mis documentos?
No, si genera los embeddings una vez y después realiza consultas. Un modelo de 137 millones de parámetros, como nomic-embed-text, funciona en CPU. Procesar por completo un corpus grande puede tardar varias horas, por lo que puede dejarlo ejecutándose durante la noche. Una GPU empieza a ser importante cuando los documentos llegan continuamente o cuando quiere ejecutar la generación en el mismo servidor. Mida el tiempo de un lote con /api/embed en su propio servidor y multiplíquelo por el número de fragmentos. El número de vCPU varía demasiado para que una cifra publicada resulte útil.
¿Por qué mi consulta vectorial usa un análisis secuencial en lugar del índice HNSW?
Lea el plan con EXPLAIN (ANALYZE, BUFFERS). La causa habitual es el almacenamiento: pgvector indica que el planificador no incluye el almacenamiento fuera de línea en sus estimaciones de coste. Por eso, un análisis secuencial parece más barato de lo que realmente es. Además, un vector de 768 dimensiones ocupa 3,080 bytes, por lo que se almacena en la tabla TOAST de forma predeterminada. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; mantiene las filas nuevas en línea. Las otras dos causas son un operador que no coincide con el índice, ya que un índice creado con vector_cosine_ops sólo se utiliza con <=>, y una consulta sin ORDER BY ... LIMIT, ya que un índice aproximado sólo atiende consultas ordenadas de vecinos más cercanos.
¿Cómo sé si mi recuperación funciona bien?
Cree un conjunto de referencia con 30 preguntas. Asocie cada pregunta al identificador del fragmento que la responde y almacene junto a ellas los embeddings de las preguntas. Después, mida el recall@10, que indica con qué frecuencia aparece el fragmento correcto entre los 10 primeros resultados, y el MRR, que recompensa que aparezca en la primera posición. Esos dos valores indican si un cambio en el tamaño de los fragmentos, el modelo de embeddings o la combinación de rankings ha mejorado el resultado. Sin esas métricas, cambiará la configuración y confiará en su impresión sobre unas pocas respuestas.